diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..61b4cbf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.dockerignore +.DS_Store + +node_modules +.next +.runtime + +.env +.env.local +.env.*.local +*.log diff --git a/.env.example b/.env.example index eafdc7a..5c161ca 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,25 @@ # Do not commit filled secret values. # Local server +APP_PORT=3000 PORT=3000 HOSTNAME=127.0.0.1 NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 +ZHINIAN_RUNTIME_DIR=.runtime +ZHINIAN_PUBLIC_BASE_URL=http://127.0.0.1:3000 + +# Public API v1 and worker task management. +# Format: clientId:key,anotherClient:anotherKey +ZHINIAN_API_KEYS=demo-agent:change-me-public-api-key +ZHINIAN_INTERNAL_WORKER_TOKEN=change-me-worker-token +ZHINIAN_WEBHOOK_SECRET= +ZHINIAN_WORKER_BASE_URL=http://127.0.0.1:3000 +ZHINIAN_WORKER_INTERVAL_MS=5000 +ZHINIAN_WORKER_BATCH_SIZE=3 +ZHINIAN_WORKER_POLL_INTERVAL_MS=5000 +ZHINIAN_WORKER_LOCK_TIMEOUT_MS=300000 +ZHINIAN_WORKER_RETRY_BASE_MS=10000 +ZHINIAN_WORKER_RETRY_MAX_MS=300000 # Supabase SaaS data layer. If empty, the app uses .runtime/data/web-app-state.json. NEXT_PUBLIC_SUPABASE_URL= @@ -52,5 +68,5 @@ ALI_OSS_ENDPOINT= ALI_OSS_BUCKET= ALI_OSS_ACCESS_KEY_ID= ALI_OSS_ACCESS_KEY_SECRET= -ALI_OSS_PREFIX=nianxxplay +ALI_OSS_PREFIX=zhinian ALI_OSS_PUBLIC_BASE_URL= diff --git a/.gitignore b/.gitignore index 58b8daf..9775467 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,3 @@ *.log .DS_Store node_modules/ -runtime/nianxx-play/node_modules/ -runtime/nianxx-play/public/ - -# Keep the legacy runtime metadata as reference, but not installed deps or bulky media. -!runtime/ -!runtime/nianxx-play/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac04fed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-alpine AS deps +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-alpine AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build && npm prune --omit=dev + +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV ZHINIAN_RUNTIME_DIR=/app/.runtime + +COPY --from=builder /app/package.json /app/package-lock.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/scripts ./scripts +COPY --from=builder /app/next.config.ts ./next.config.ts + +RUN mkdir -p /app/.runtime/data /app/.runtime/uploads /app/.runtime/generated-results + +EXPOSE 3000 + +CMD ["sh", "-c", "npx next start --hostname 0.0.0.0 --port ${PORT:-3000}"] diff --git a/README.md b/README.md index 43e070f..9cb4e87 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,19 @@ # 智念AIGC平台 +[完整中文说明](./README.zh-CN.md) + 这是 `智念AIGC平台` 的 Web 极简 MVP。当前产品只保留核心闭环:统一创作图片/视频、查看结果、局部重绘、智能超清和必要设置。 -旧的 `runtime/nianxx-play` standalone 运行包只作为旧流程、样例素材和 Seedance 参考,不再作为主应用入口。仓库只保留旧运行时元数据;大体积本地媒体与依赖不纳入代码提交。 - ## 启动 +服务器一键部署: + +```bash +bash scripts/deploy.sh +``` + +开发环境: + ```bash cd /Users/inmanx/Documents/zhinian-creation-assistant npm install @@ -22,6 +30,8 @@ http://127.0.0.1:3000 ```bash npm start +npm run start:server +npm run worker npm run build npm test npm run health @@ -30,11 +40,7 @@ npm run info `npm start` 会自动先执行一次生产构建,再启动 `http://127.0.0.1:3000`;开发调试建议继续使用 `npm run dev`。 -旧 runtime 仍可手动启动: - -```bash -npm run legacy:start -``` +Docker 部署默认使用 `docker-compose.yml` 同时启动 Web 服务和 `zhinian-worker` 任务 Worker,访问 `http://服务器IP:3000`。如需修改端口,调整 `.env.local` 中的 `APP_PORT` 和 `NEXT_PUBLIC_APP_URL` 后重新执行 `bash scripts/deploy.sh`。 ## Web MVP 信息架构 @@ -145,6 +151,30 @@ supabase/schema.sql - `generation_jobs` - `usage_events` +## 任务管理与开放 API + +平台支持服务端任务管理:页面和 `/api/v1` 创建任务后只入队,Worker 统一提交供应商、轮询、转存结果、失败重试和 Webhook 回调。生产部署建议配置 Supabase/Postgres;本地开发可继续使用 `.runtime/data/web-app-state.json`。 + +开放 API 使用 API Key: + +```env +ZHINIAN_API_KEYS=demo-agent:change-me-public-api-key +ZHINIAN_INTERNAL_WORKER_TOKEN=change-me-worker-token +``` + +主要接口: + +- `GET /api/v1/capabilities` +- `POST /api/v1/assets` +- `GET /api/v1/assets` +- `POST /api/v1/jobs` +- `GET /api/v1/jobs` +- `GET /api/v1/jobs/[id]` +- `POST /api/v1/jobs/[id]/cancel` +- `GET /api/v1/openapi.json` + +任务创建支持 `Idempotency-Key` 幂等键和 `webhookUrl` 完成回调。Worker 可用 `npm run worker` 常驻运行,或 `npm run worker:once` 单次处理。 + ## API 核心图片 API: diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..4bcaa50 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,267 @@ +# 智念AIGC平台中文说明 + +智念AIGC平台是一个面向图片与视频创作的 Web 工作台。当前版本聚焦核心生产链路:提示词创作、素材上传、图片生成、视频生成、局部重绘、智能超清、历史资产管理和接口配置。 + +## 功能概览 + +- 统一创作入口:`/create` +- 结果资产管理:`/assets` +- 服务与引擎配置:`/settings` +- 图片生成:即梦图片生成 4.6 或 EvoLink GPT Image 2 +- 图片编辑:局部重绘、智能超清 +- 视频生成:Seedance 2.0 +- 素材引用:上传后可在提示词中使用 `@图片1`、`@视频1`、`@音频1` +- 本地开发兜底:未配置真实接口时,可使用 mock 流程完成产品验收 + +## 技术栈 + +- Next.js 15 +- React 19 +- TypeScript +- GSAP +- Supabase/Postgres 可选 +- Aliyun OSS 可选 +- Vitest + +## 快速启动 + +开发环境: + +```bash +npm install +cp .env.example .env.local +npm run dev -- --hostname 127.0.0.1 --port 3000 +``` + +访问地址: + +```text +http://127.0.0.1:3000 +``` + +生产模式: + +```bash +npm start +``` + +`npm start` 会先执行 `next build`,再启动 `127.0.0.1:3000`。 + +## 服务器一键部署 + +服务器推荐使用 Docker Compose: + +```bash +git clone <你的仓库地址> +cd NianAIGC +bash scripts/deploy.sh +``` + +脚本会自动完成: + +- 创建 `.env.local` +- 创建 `.runtime/data`、`.runtime/uploads`、`.runtime/generated-results` +- 构建 Docker 镜像 +- 使用 `docker compose up -d --build` 后台启动 Web 服务和 Worker 服务 + +默认访问: + +```text +http://服务器IP:3000 +``` + +如果要换端口,可以编辑 `.env.local`: + +```env +APP_PORT=8080 +NEXT_PUBLIC_APP_URL=http://你的服务器IP:8080 +``` + +然后重新执行: + +```bash +bash scripts/deploy.sh +``` + +常用 Docker 命令: + +```bash +docker compose ps +docker compose logs -f zhinian-aigc +docker compose logs -f zhinian-worker +docker compose restart +docker compose down +``` + +如果服务器不用 Docker,也可以用 Node 直接部署: + +```bash +npm ci +npm run build +npm run start:server +``` + +另开一个进程运行任务 Worker: + +```bash +npm run worker +``` + +生产环境建议把 `NEXT_PUBLIC_APP_URL` 设置成真实域名或公网地址,配置 `ZHINIAN_INTERNAL_WORKER_TOKEN`,并把 `.runtime/` 做定期备份。 + +## 常用命令 + +```bash +npm run dev -- --hostname 127.0.0.1 --port 3000 +npm test +npm run build +npm run health +npm run worker:once +npm run info +``` + +## 页面路由 + +| 路由 | 用途 | +|------|------| +| `/` | 自动跳转到 `/create` | +| `/create` | 统一创作入口 | +| `/create?mode=video` | 视频生成模式 | +| `/create?mode=inpaint` | 局部重绘模式 | +| `/create?mode=upscale` | 智能超清模式 | +| `/assets` | 历史任务与资产 | +| `/settings` | 接口、引擎和服务配置 | + +## 引擎说明 + +### 图片生成 + +图片生成可在设置页按能力切换: + +- `jimeng`:火山 Visual 即梦能力 +- `evolink`:EvoLink GPT Image 2 中转接口 + +### 图片编辑 + +- 局部重绘:即梦 inpainting 或 EvoLink inpaint +- 智能超清:即梦超清能力 + +### 视频生成 + +视频生成使用 Seedance 2.0。 + +当前参数限制已按官方接口收口: + +- `duration`:`4` 到 `15` 的整数秒 +- `duration=-1`:允许在环境变量或服务端归一化中表示模型自动选择 +- `ratio`:`16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive` +- `resolution`:`480p`、`720p`、`1080p` +- Seedance 2.0 fast 不支持 `1080p` + +## 任务管理与开放 API + +平台现在支持服务端任务管理:页面和开放 API 提交任务后只写入 `queued`,由 Worker 统一提交供应商、轮询状态、导入资产、重试和触发 Webhook。这个实现不是 Redis/BullMQ 消息队列,而是基于 `generation_jobs` 的任务状态机、锁和调度字段。 + +开放 API 使用 API Key: + +```env +ZHINIAN_API_KEYS=demo-agent:change-me-public-api-key +ZHINIAN_INTERNAL_WORKER_TOKEN=change-me-worker-token +``` + +调用示例: + +```bash +curl -X POST http://127.0.0.1:3000/api/v1/jobs \ + -H 'Authorization: Bearer change-me-public-api-key' \ + -H 'Content-Type: application/json' \ + -H 'Idempotency-Key: demo-job-001' \ + -d '{"capability":"image.generate","prompt":"生成一张专业产品主图"}' +``` + +主要接口: + +| 接口 | 说明 | +|------|------| +| `GET /api/v1/capabilities` | 查询图片、修图、超清、视频能力 | +| `POST /api/v1/assets` | 上传文件或注册外部素材 URL | +| `GET /api/v1/assets` | 查询素材 | +| `POST /api/v1/jobs` | 创建生成任务 | +| `GET /api/v1/jobs` | 按状态、能力、时间分页查询任务 | +| `GET /api/v1/jobs/:id` | 查询单个任务 | +| `POST /api/v1/jobs/:id/cancel` | 取消未完成任务 | +| `GET /api/v1/openapi.json` | OpenAPI 描述 | + +幂等规则:同一 API Client 使用同一个 `Idempotency-Key` 重复提交相同请求时返回已有任务;请求内容不同会返回 `409`。 + +Webhook:创建任务时传 `webhookUrl`,任务进入 `succeeded`、`failed`、`cancelled` 或 `expired` 后会回调。配置 `ZHINIAN_WEBHOOK_SECRET` 后,请求头会带 `X-Zhinian-Signature: sha256=...`。 + +## 环境变量 + +复制 `.env.example` 后按需配置: + +```bash +cp .env.example .env.local +``` + +核心变量: + +| 变量 | 说明 | +|------|------| +| `APP_PORT` | Docker Compose 对外暴露端口,默认 `3000` | +| `NEXT_PUBLIC_APP_URL` | 对外访问地址,用于生成回调/本地文件 URL | +| `ZHINIAN_API_KEYS` | 开放 API Key,格式 `clientId:key,clientId2:key2` | +| `ZHINIAN_INTERNAL_WORKER_TOKEN` | 内部 Worker tick 接口令牌 | +| `ZHINIAN_WEBHOOK_SECRET` | Webhook 签名密钥,可选 | +| `ZHINIAN_WORKER_*` | Worker 间隔、批量、锁超时、重试配置 | +| `IMAGE_GENERATE_ENGINE` | 图片生成引擎:`jimeng` 或 `evolink` | +| `IMAGE_INPAINT_ENGINE` | 局部重绘引擎:`jimeng` 或 `evolink` | +| `VOLCENGINE_ACCESS_KEY_ID` | 火山引擎 Access Key | +| `VOLCENGINE_SECRET_ACCESS_KEY` | 火山引擎 Secret Key | +| `EVOLINK_API_KEY` | EvoLink API Key | +| `SEEDANCE_API_KEY` | 火山方舟 Seedance API Key | +| `SEEDANCE_MODEL` | Seedance 模型 ID | +| `SEEDANCE_RATIO` | 默认视频比例 | +| `SEEDANCE_DURATION` | 默认视频秒数 | +| `SEEDANCE_RESOLUTION` | 默认视频分辨率 | +| `ALI_OSS_*` | 上传素材和生成结果转存配置 | +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase URL | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase 匿名 Key | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase 服务端 Key | + +未配置 Supabase 时,应用会使用 `.runtime/data/web-app-state.json` 作为本地开发数据层。未配置 OSS 时,上传和生成结果会写入 `.runtime/uploads` 与 `.runtime/generated-results`。 + +## 项目结构 + +```text +app/ Next.js App Router 页面与 API +components/ 前端组件 +lib/ 业务逻辑、服务端适配器、接口客户端 +lib/ui/motion.ts GSAP 动效工具层 +public/logo/ 品牌 Logo +scripts/ 启动、健康检查与信息脚本 +supabase/schema.sql 数据库结构 +tests/ Vitest 测试 +Dockerfile Docker 镜像构建 +docker-compose.yml 服务器部署编排 +``` + +## 验证 + +推荐提交前执行: + +```bash +npm test +npm run build +npm run health +npm run worker:once +``` + +如果本地 dev server 正在运行,建议先停止后再执行生产构建,避免 Next.js dev 缓存与生产构建互相影响。 + +## 注意事项 + +- `.env.local` 不应提交到仓库。 +- `.next/`、`.runtime/`、`node_modules/` 不应提交到仓库。 +- 当前主产品名为 `智念AIGC平台`。 +- 顶部 Logo 使用 `public/logo/zhinian-logo.png`。 diff --git a/app/api/assets/[id]/download/route.ts b/app/api/assets/[id]/download/route.ts new file mode 100644 index 0000000..4dc5a7e --- /dev/null +++ b/app/api/assets/[id]/download/route.ts @@ -0,0 +1,42 @@ +import { getAsset } from "@/lib/server/data-store"; +import { jsonError } from "@/lib/server/api"; +import { readAssetForDownload } from "@/lib/server/storage"; + +export const runtime = "nodejs"; + +export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const asset = await getAsset(id); + if (!asset) return jsonError("资产不存在", 404); + const file = await readAssetForDownload(asset); + if (!file) return jsonError("资产文件不可下载", 404); + return new Response(new Uint8Array(file.bytes), { + headers: { + "Content-Type": file.contentType, + "Content-Length": String(file.bytes.length), + "Content-Disposition": contentDisposition(asset.name || `${asset.id}${extensionForContentType(file.contentType)}`), + "Cache-Control": "private, no-store" + } + }); + } catch (error) { + return jsonError(error, 500); + } +} + +function contentDisposition(fileName: string): string { + const clean = fileName.replace(/[\r\n/\\]/g, "_").trim() || "download"; + const ascii = clean.replace(/[^\x20-\x7E]/g, "_").replace(/"/g, "_"); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(clean)}`; +} + +function extensionForContentType(contentType: string): string { + const normalized = contentType.split(";")[0]?.trim().toLowerCase(); + if (normalized === "image/png") return ".png"; + if (normalized === "image/jpeg") return ".jpg"; + if (normalized === "image/webp") return ".webp"; + if (normalized === "image/svg+xml") return ".svg"; + if (normalized === "video/mp4") return ".mp4"; + if (normalized === "audio/mpeg") return ".mp3"; + return ""; +} diff --git a/app/api/generations/image/[id]/route.ts b/app/api/generations/image/[id]/route.ts index 2d80caa..2600d1d 100644 --- a/app/api/generations/image/[id]/route.ts +++ b/app/api/generations/image/[id]/route.ts @@ -1,17 +1,14 @@ import { deleteAsset, deleteGenerationJob, getAsset, getGenerationJob } from "@/lib/server/data-store"; import { jsonError, jsonOk } from "@/lib/server/api"; -import { requestOrigin } from "@/lib/server/runtime"; -import { syncImageJob } from "@/lib/server/generation-service"; import { deleteStoredAsset } from "@/lib/server/storage"; export const runtime = "nodejs"; -export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { +export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { try { const { id } = await context.params; - const existing = await getGenerationJob(id); - if (!existing) return jsonError(new Error("Generation job not found."), 404); - const job = await syncImageJob(id, requestOrigin(request)); + const job = await getGenerationJob(id); + if (!job) return jsonError(new Error("Generation job not found."), 404); return jsonOk({ job }); } catch (error) { return jsonError(error, 500); diff --git a/app/api/generations/video/[id]/route.ts b/app/api/generations/video/[id]/route.ts index 4809911..a11e6a8 100644 --- a/app/api/generations/video/[id]/route.ts +++ b/app/api/generations/video/[id]/route.ts @@ -1,15 +1,14 @@ import { deleteAsset, deleteGenerationJob, getAsset, getGenerationJob } from "@/lib/server/data-store"; import { jsonError, jsonOk } from "@/lib/server/api"; -import { requestOrigin } from "@/lib/server/runtime"; -import { syncVideoJob } from "@/lib/server/video-generation-service"; import { deleteStoredAsset } from "@/lib/server/storage"; export const runtime = "nodejs"; -export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { +export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { try { const { id } = await context.params; - const job = await syncVideoJob(id, requestOrigin(request)); + const job = await getGenerationJob(id); + if (!job) return jsonError(new Error("Generation job not found."), 404); return jsonOk({ job }); } catch (error) { return jsonError(error, 500); diff --git a/app/api/internal/worker/tick/route.ts b/app/api/internal/worker/tick/route.ts new file mode 100644 index 0000000..e7811f4 --- /dev/null +++ b/app/api/internal/worker/tick/route.ts @@ -0,0 +1,24 @@ +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { assertInternalWorkerToken, PublicApiAuthError } from "@/lib/server/public-api-auth"; +import { runWorkerTick } from "@/lib/server/task-manager"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + assertInternalWorkerToken(request); + const body = await readJsonBody<{ + workerId?: string; + limit?: number; + }>(request); + const result = await runWorkerTick({ + request, + workerId: body.workerId, + limit: typeof body.limit === "number" ? body.limit : undefined + }); + return jsonOk(result); + } catch (error) { + if (error instanceof PublicApiAuthError) return jsonError(error.message, error.status); + return jsonError(error, 500); + } +} diff --git a/app/api/v1/assets/route.ts b/app/api/v1/assets/route.ts new file mode 100644 index 0000000..b87310a --- /dev/null +++ b/app/api/v1/assets/route.ts @@ -0,0 +1,66 @@ +import { createAsset, listAssets } from "@/lib/server/data-store"; +import { jsonOk, readJsonBody } from "@/lib/server/api"; +import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { publicApiError } from "@/lib/server/public-api-response"; +import { DEFAULT_OWNER_ID, requestOrigin } from "@/lib/server/runtime"; +import { saveUploadAsset } from "@/lib/server/storage"; +import type { AssetKind } from "@/lib/types"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + authenticatePublicApiRequest(request); + return jsonOk({ assets: await listAssets(DEFAULT_OWNER_ID) }); + } catch (error) { + return publicApiError(error); + } +} + +export async function POST(request: Request) { + try { + const client = authenticatePublicApiRequest(request); + const contentType = request.headers.get("content-type") || ""; + if (contentType.includes("multipart/form-data")) { + const form = await request.formData(); + const files = form.getAll("files").filter((item): item is File => item instanceof File); + if (!files.length) throw new Error("No files uploaded."); + const assets = await Promise.all(files.map(async (file) => saveUploadAsset({ + ownerId: DEFAULT_OWNER_ID, + bytes: await awaitFileBytes(file), + fileName: file.name, + contentType: file.type || "application/octet-stream", + origin: requestOrigin(request), + tags: ["upload", "public-api", `api-client:${client.id}`] + }))); + return jsonOk({ assets }, { status: 201 }); + } + + const body = await readJsonBody<{ + url?: string; + name?: string; + kind?: AssetKind; + tags?: string[]; + }>(request); + if (!body.url) throw new Error("url is required"); + const asset = await createAsset({ + ownerId: DEFAULT_OWNER_ID, + kind: body.kind || "image", + name: body.name || "外部素材", + url: body.url, + source: "external", + tags: [...(body.tags || []), "external", "public-api", `api-client:${client.id}`], + metadata: { + registeredFrom: "public-api", + externalClientId: client.id + } + }); + return jsonOk({ asset }, { status: 201 }); + } catch (error) { + return publicApiError(error); + } +} + +async function awaitFileBytes(file: File): Promise { + return Buffer.from(await file.arrayBuffer()); +} diff --git a/app/api/v1/capabilities/route.ts b/app/api/v1/capabilities/route.ts new file mode 100644 index 0000000..42b0967 --- /dev/null +++ b/app/api/v1/capabilities/route.ts @@ -0,0 +1,45 @@ +import { getEffectiveImageEngine, getEvolinkImageSettings } from "@/lib/evolink/image-client"; +import { getVisibleImageCapabilities } from "@/lib/jimeng/capabilities"; +import { jsonOk } from "@/lib/server/api"; +import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { publicApiError } from "@/lib/server/public-api-response"; +import { getSeedanceConfig } from "@/lib/seedance/client"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + authenticatePublicApiRequest(request); + const evolink = getEvolinkImageSettings(); + return jsonOk({ + capabilities: [ + ...getVisibleImageCapabilities().map((capability) => { + const engine = getEffectiveImageEngine(capability.id); + return { + id: capability.id, + label: capability.label, + kind: "image", + engine, + provider: engine === "evolink" ? "evolink" : "volcengine-visual", + reqKey: engine === "evolink" ? evolink.model : capability.reqKey + }; + }), + { + id: "video.generate", + label: "Seedance 视频生成", + kind: "video", + engine: "seedance", + provider: "seedance", + reqKey: getSeedanceConfig().model, + limits: { + durationSeconds: { min: 4, max: 15 }, + ratios: ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"], + resolutions: ["480p", "720p", "1080p"] + } + } + ] + }); + } catch (error) { + return publicApiError(error); + } +} diff --git a/app/api/v1/jobs/[id]/cancel/route.ts b/app/api/v1/jobs/[id]/cancel/route.ts new file mode 100644 index 0000000..1b5144a --- /dev/null +++ b/app/api/v1/jobs/[id]/cancel/route.ts @@ -0,0 +1,26 @@ +import { clearGenerationJobLock, getGenerationJob, updateGenerationJob } from "@/lib/server/data-store"; +import { jsonError, jsonOk } from "@/lib/server/api"; +import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { publicApiError } from "@/lib/server/public-api-response"; + +export const runtime = "nodejs"; + +export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { + try { + const client = authenticatePublicApiRequest(request); + const { id } = await context.params; + const job = await getGenerationJob(id); + if (!job || job.externalClientId !== client.id) return jsonError("Job not found.", 404); + if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) { + return jsonOk({ job }); + } + await updateGenerationJob(id, { + status: "cancelled", + completedAt: new Date().toISOString() + }); + const cancelled = await clearGenerationJobLock(id); + return jsonOk({ job: cancelled }); + } catch (error) { + return publicApiError(error); + } +} diff --git a/app/api/v1/jobs/[id]/route.ts b/app/api/v1/jobs/[id]/route.ts new file mode 100644 index 0000000..3f0115d --- /dev/null +++ b/app/api/v1/jobs/[id]/route.ts @@ -0,0 +1,18 @@ +import { getGenerationJob } from "@/lib/server/data-store"; +import { jsonError, jsonOk } from "@/lib/server/api"; +import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { publicApiError } from "@/lib/server/public-api-response"; + +export const runtime = "nodejs"; + +export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { + try { + const client = authenticatePublicApiRequest(request); + const { id } = await context.params; + const job = await getGenerationJob(id); + if (!job || job.externalClientId !== client.id) return jsonError("Job not found.", 404); + return jsonOk({ job }); + } catch (error) { + return publicApiError(error); + } +} diff --git a/app/api/v1/jobs/route.ts b/app/api/v1/jobs/route.ts new file mode 100644 index 0000000..a59f20b --- /dev/null +++ b/app/api/v1/jobs/route.ts @@ -0,0 +1,65 @@ +import { listGenerationJobsFiltered } from "@/lib/server/data-store"; +import { jsonOk, readJsonBody } from "@/lib/server/api"; +import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { createPublicGenerationJob, type PublicJobCreateBody } from "@/lib/server/public-api-jobs"; +import { publicApiError } from "@/lib/server/public-api-response"; +import { DEFAULT_OWNER_ID, requestOrigin } from "@/lib/server/runtime"; +import type { GenerationCapability, GenerationStatus } from "@/lib/types"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + const client = authenticatePublicApiRequest(request); + const url = new URL(request.url); + const jobs = await listGenerationJobsFiltered({ + ownerId: DEFAULT_OWNER_ID, + externalClientId: client.id, + status: parseStatus(url.searchParams.get("status")), + capability: parseCapability(url.searchParams.get("capability")), + limit: parseLimit(url.searchParams.get("limit")), + before: url.searchParams.get("before") || undefined + }); + return jsonOk({ jobs }); + } catch (error) { + return publicApiError(error); + } +} + +export async function POST(request: Request) { + try { + const client = authenticatePublicApiRequest(request); + const body = await readJsonBody(request); + const result = await createPublicGenerationJob({ + client, + body, + request, + origin: requestOrigin(request) + }); + return jsonOk({ job: result.job, reused: result.reused }, { status: result.reused ? 200 : 202 }); + } catch (error) { + return publicApiError(error); + } +} + +function parseStatus(value: string | null): GenerationStatus | undefined { + if (!value) return undefined; + if (["queued", "running", "succeeded", "failed", "expired", "cancelled"].includes(value)) { + return value as GenerationStatus; + } + throw new Error(`Unsupported status filter: ${value}`); +} + +function parseCapability(value: string | null): GenerationCapability | undefined { + if (!value) return undefined; + if (["image.generate", "image.inpaint", "image.upscale", "video.generate"].includes(value)) { + return value as GenerationCapability; + } + throw new Error(`Unsupported capability filter: ${value}`); +} + +function parseLimit(value: string | null): number { + const parsed = Number(value || 50); + if (!Number.isFinite(parsed)) return 50; + return Math.max(1, Math.min(200, Math.trunc(parsed))); +} diff --git a/app/api/v1/openapi.json/route.ts b/app/api/v1/openapi.json/route.ts new file mode 100644 index 0000000..829afd3 --- /dev/null +++ b/app/api/v1/openapi.json/route.ts @@ -0,0 +1,47 @@ +import { jsonOk } from "@/lib/server/api"; + +export const runtime = "nodejs"; + +export async function GET() { + return jsonOk({ + openapi: "3.1.0", + info: { + title: "智念AIGC平台 Public API", + version: "1.0.0" + }, + security: [{ bearerApiKey: [] }, { headerApiKey: [] }], + components: { + securitySchemes: { + bearerApiKey: { type: "http", scheme: "bearer" }, + headerApiKey: { type: "apiKey", in: "header", name: "X-Zhinian-Api-Key" } + } + }, + paths: { + "/api/v1/capabilities": { + get: { summary: "List generation capabilities", responses: { "200": { description: "Capabilities" } } } + }, + "/api/v1/assets": { + get: { summary: "List assets", responses: { "200": { description: "Assets" } } }, + post: { summary: "Upload files or register an external asset URL", responses: { "201": { description: "Created asset" } } } + }, + "/api/v1/jobs": { + get: { summary: "List jobs", responses: { "200": { description: "Jobs" } } }, + post: { summary: "Create a queued generation job", responses: { "202": { description: "Queued job" }, "409": { description: "Idempotency conflict" } } } + }, + "/api/v1/jobs/{id}": { + get: { + summary: "Get one job", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + responses: { "200": { description: "Job" }, "404": { description: "Not found" } } + } + }, + "/api/v1/jobs/{id}/cancel": { + post: { + summary: "Cancel a queued or running job", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + responses: { "200": { description: "Cancelled job" }, "404": { description: "Not found" } } + } + } + } + }); +} diff --git a/app/globals.css b/app/globals.css index bb3ec7d..30786d1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -18,6 +18,11 @@ box-sizing: border-box; } +html, +body { + overflow-x: clip; +} + body { margin: 0; background: var(--bg); @@ -1013,6 +1018,13 @@ h3 { gap: 2px; } +.asset-preview-actions { + flex: 0 0 auto; + display: flex !important; + align-items: center; + gap: 8px; +} + .asset-preview-head strong, .asset-preview-head span { min-width: 0; diff --git a/app/planning-cases/[...path]/route.ts b/app/planning-cases/[...path]/route.ts deleted file mode 100644 index c19b4b7..0000000 --- a/app/planning-cases/[...path]/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { readLegacyPublicFile } from "@/lib/server/storage"; - -export const runtime = "nodejs"; - -export async function GET(_request: Request, context: { params: Promise<{ path: string[] }> }) { - const { path } = await context.params; - const file = await readLegacyPublicFile(["planning-cases", ...path]); - if (!file) return new Response("Not found", { status: 404 }); - return new Response(new Uint8Array(file.bytes), { - headers: { - "Content-Type": file.contentType, - "Cache-Control": "public, max-age=31536000, immutable" - } - }); -} diff --git a/app/seedance-starter-assets/[...path]/route.ts b/app/seedance-starter-assets/[...path]/route.ts deleted file mode 100644 index a18fbcf..0000000 --- a/app/seedance-starter-assets/[...path]/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { readLegacyPublicFile } from "@/lib/server/storage"; - -export const runtime = "nodejs"; - -export async function GET(_request: Request, context: { params: Promise<{ path: string[] }> }) { - const { path } = await context.params; - const file = await readLegacyPublicFile(["seedance-starter-assets", ...path]); - if (!file) return new Response("Not found", { status: 404 }); - return new Response(new Uint8Array(file.bytes), { - headers: { - "Content-Type": file.contentType, - "Cache-Control": "public, max-age=31536000, immutable" - } - }); -} diff --git a/app/starter/[...path]/route.ts b/app/starter/[...path]/route.ts deleted file mode 100644 index c6734cc..0000000 --- a/app/starter/[...path]/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { readLegacyPublicFile } from "@/lib/server/storage"; - -export const runtime = "nodejs"; - -export async function GET(_request: Request, context: { params: Promise<{ path: string[] }> }) { - const { path } = await context.params; - const file = await readLegacyPublicFile(["starter", ...path]); - if (!file) return new Response("Not found", { status: 404 }); - return new Response(new Uint8Array(file.bytes), { - headers: { - "Content-Type": file.contentType, - "Cache-Control": "public, max-age=31536000, immutable" - } - }); -} diff --git a/components/asset-manager.tsx b/components/asset-manager.tsx index 1a71198..39873ca 100644 --- a/components/asset-manager.tsx +++ b/components/asset-manager.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Eye, ImageIcon, Info, Loader2, Music, RefreshCw, Trash2, X } from "lucide-react"; +import { Download, Eye, ImageIcon, Info, Loader2, Music, RefreshCw, Trash2, X } from "lucide-react"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { modalEnter, modalExit, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import type { Asset, GenerationJob } from "@/lib/types"; @@ -208,6 +208,9 @@ export function AssetManager() { + + + @@ -264,9 +267,14 @@ export function AssetManager() { {previewAsset.name} {kindLabel(previewAsset)} / {sourceLabel(previewAsset.source)} / {formatDate(previewAsset.createdAt)} - +
+ + + + +
{renderAssetPreviewLarge(previewAsset)} @@ -372,6 +380,10 @@ function renderAssetPreviewLarge(asset: Asset) { ); } +function assetDownloadUrl(asset: Asset) { + return `/api/assets/${encodeURIComponent(asset.id)}/download`; +} + function isResultAsset(asset: Asset, outputAssetIds: Set) { if (!outputAssetIds.has(asset.id)) return false; if (asset.kind === "mask" || asset.tags.includes("mask") || typeof asset.metadata.maskRule === "string") return false; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..100dc8f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + zhinian-aigc: + build: + context: . + dockerfile: Dockerfile + image: zhinian-aigc:latest + container_name: zhinian-aigc + restart: unless-stopped + env_file: + - .env.local + environment: + NODE_ENV: production + PORT: 3000 + ZHINIAN_RUNTIME_DIR: /app/.runtime + NEXT_TELEMETRY_DISABLED: "1" + ports: + - "${APP_PORT:-3000}:3000" + volumes: + - ./.runtime:/app/.runtime + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://127.0.0.1:3000/api/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + zhinian-worker: + image: zhinian-aigc:latest + container_name: zhinian-worker + restart: unless-stopped + depends_on: + zhinian-aigc: + condition: service_healthy + env_file: + - .env.local + environment: + NODE_ENV: production + ZHINIAN_RUNTIME_DIR: /app/.runtime + NEXT_TELEMETRY_DISABLED: "1" + ZHINIAN_WORKER_BASE_URL: http://zhinian-aigc:3000 + volumes: + - ./.runtime:/app/.runtime + command: ["node", "scripts/worker.mjs"] diff --git a/docs/EXTRACTION_NOTES.md b/docs/EXTRACTION_NOTES.md deleted file mode 100644 index b14b606..0000000 --- a/docs/EXTRACTION_NOTES.md +++ /dev/null @@ -1,32 +0,0 @@ -# Zhinian Creation Assistant Extraction Notes - -## Source - -- Desktop project: `/Users/inmanx/Documents/念/yinian-desktop` -- Extracted runtime source: `/Users/inmanx/Documents/念/yinian-desktop/build/apps/nianxx-play` -- New standalone project: `/Users/inmanx/Documents/zhinian-creation-assistant` - -The original NianxxPlay source project was deleted before this extraction. A broader `/Users/inmanx` search found only desktop app runtime data and Electron partition state, not a source project. - -## What Was Copied - -- Next.js standalone server runtime. -- `.next` server/static output. -- Runtime `node_modules` required by the standalone server. -- Public/reference media. -- Content manifests and planning cases. - -## What Was Excluded - -- `.env.runtime` from the desktop bundle, because it is marked `internal-testing-only`. -- User uploads and generated results. -- Electron partition/session data. -- Desktop app Host API and Electron process manager code. - -## Current Boundary - -This is an independent runnable project for Zhinian Creation Assistant, but not yet a full source-development project. The next step for product development should be either: - -1. Rebuild the app source from the runtime behavior and content manifests. -2. Keep this runtime project as a deployable artifact while creating a fresh source app beside it. -3. Gradually replace the extracted runtime with reproducible builds from the new source. diff --git a/findings.md b/findings.md index cc6991b..54db6e0 100644 --- a/findings.md +++ b/findings.md @@ -6,20 +6,20 @@ ## Research Findings - Top-level project is not a Git repository; `git status --short` returned "fatal: not a git repository". -- Root contains orchestration/docs files plus a `runtime/nianxx-play` application directory. -- `runtime/nianxx-play/node_modules` is present, so dependencies appear already installed for the embedded runtime app. -- The initial full file scan showed many bundled media assets under `runtime/nianxx-play/public`, especially starter/planning/Seedance examples. +- Root contains orchestration/docs files plus a `removed extracted runtime` application directory. +- `removed extracted runtime/node_modules` is present, so dependencies appear already installed for the embedded runtime app. +- The initial full file scan showed many bundled media assets under `removed extracted runtime/public`, especially starter/planning/Seedance examples. - `README.md` states this was extracted from the `智念助手` desktop app into an independent `智念创作助手` project. -- The project is based on an existing NianxxPlay Next.js standalone runtime; original source was deleted, so this is not a full source restoration. -- Root `package.json` only orchestrates scripts: `start`/`dev` call `scripts/start-runtime.mjs`, `health` calls `scripts/health-check.mjs`, and `info` calls `scripts/print-runtime-info.mjs`. +- The project is based on an existing extracted runtime Next.js standalone runtime; original source was deleted, so this is not a full source restoration. +- Root `package.json` only orchestrates scripts: `start`/`dev` call `removed runtime start script`, `health` calls `scripts/health-check.mjs`, and `info` calls `removed runtime info script`. - Runtime package uses Next `^15.1.4`, React `^19.0.0`, Supabase client, Ali OSS, lucide-react, TypeScript, Vitest, and ESLint, but it is treated as generated runtime. -- Runtime state should be written to root `.runtime/`, not under `runtime/nianxx-play`. -- `scripts/start-runtime.mjs` loads `.env` and `.env.local`, optionally bundled `.env.runtime` only when `NIANXXPLAY_LOAD_BUNDLED_ENV=1`. -- Startup creates `.runtime/data`, `.runtime/uploads`, and `.runtime/generated-results`, then launches `runtime/nianxx-play/server.js` with `NODE_ENV=production`. -- Health check targets `/api/desktop/health` and expects JSON with `appId: "nianxx-play"` and `ok: true`. +- Runtime state should be written to root `.runtime/`, not under `removed extracted runtime`. +- `removed runtime start script` loads `.env` and `.env.local`, optionally bundled `.env.runtime` only when `ZHINIAN_LOAD_BUNDLED_ENV=1`. +- Startup creates `.runtime/data`, `.runtime/uploads`, and `.runtime/generated-results`, then launches `removed extracted runtime/server.js` with `NODE_ENV=production`. +- Health check targets `/api/desktop/health` and expects JSON with `appId: "removed-runtime"` and `ok: true`. - `.env.example` shows the real generation path depends on Seedance / Volcengine Ark plus Aliyun OSS configuration. - Extraction notes confirm copied assets include Next standalone server runtime, `.next` output, runtime `node_modules`, public/reference media, content manifests, and planning cases; secrets, user uploads, generated results, and Electron host/process manager code were excluded. -- `npm run info` succeeded and reports runtime app id `nianxx-play`, bundle timestamp `2026-05-14T04:01:58.653Z`, entry `server.js`, and size `949,760,759` bytes. +- `npm run info` succeeded and reports runtime app id `removed-runtime`, bundle timestamp `2026-05-14T04:01:58.653Z`, entry `server.js`, and size `949,760,759` bytes. - App routes include: `/`, `/studio`, `/studio/[mode]`, `/planning`, `/projects`, `/projects/[id]`, and `/billing`. - API routes include: `/api/assets`, `/api/assets/upload`, `/api/billing`, `/api/desktop/health`, `/api/generations`, `/api/generations/[id]`, `/api/generations/[id]/retry`, `/api/projects`, `/api/projects/[id]`, `/api/prompt/assemble`, and `/api/reference-templates`. - File-serving routes expose runtime uploads and generated results via `/uploads/[...path]` and `/generated-results/[...path]`. @@ -62,22 +62,22 @@ ## Resources - Project root: `/Users/inmanx/Documents/zhinian-creation-assistant` -- Runtime app: `/Users/inmanx/Documents/zhinian-creation-assistant/runtime/nianxx-play` +- Runtime app: `/Users/inmanx/Documents/zhinian-creation-assistant/removed extracted runtime` - Root README: `/Users/inmanx/Documents/zhinian-creation-assistant/README.md` - Runtime README: `/Users/inmanx/Documents/zhinian-creation-assistant/runtime/README.md` -- Startup script: `/Users/inmanx/Documents/zhinian-creation-assistant/scripts/start-runtime.mjs` +- Startup script: `/Users/inmanx/Documents/zhinian-creation-assistant/removed runtime start script` - Health script: `/Users/inmanx/Documents/zhinian-creation-assistant/scripts/health-check.mjs` -- Runtime info script: `/Users/inmanx/Documents/zhinian-creation-assistant/scripts/print-runtime-info.mjs` +- Runtime info script: `/Users/inmanx/Documents/zhinian-creation-assistant/removed runtime info script` - Extraction notes: `/Users/inmanx/Documents/zhinian-creation-assistant/docs/EXTRACTION_NOTES.md` -- App paths manifest: `/Users/inmanx/Documents/zhinian-creation-assistant/runtime/nianxx-play/.next/server/app-paths-manifest.json` -- Compiled projects API: `/Users/inmanx/Documents/zhinian-creation-assistant/runtime/nianxx-play/.next/server/app/api/projects/route.js` -- Compiled upload API: `/Users/inmanx/Documents/zhinian-creation-assistant/runtime/nianxx-play/.next/server/app/api/assets/upload/route.js` -- Compiled prompt assembly API: `/Users/inmanx/Documents/zhinian-creation-assistant/runtime/nianxx-play/.next/server/app/api/prompt/assemble/route.js` -- Compiled generation polling API: `/Users/inmanx/Documents/zhinian-creation-assistant/runtime/nianxx-play/.next/server/app/api/generations/[id]/route.js` -- Compiled generation retry API: `/Users/inmanx/Documents/zhinian-creation-assistant/runtime/nianxx-play/.next/server/app/api/generations/[id]/retry/route.js` -- Creation modes JSON: `/Users/inmanx/Documents/zhinian-creation-assistant/runtime/nianxx-play/content/seedance-starter/creation-modes.json` -- Starter catalog JSON: `/Users/inmanx/Documents/zhinian-creation-assistant/runtime/nianxx-play/content/seedance-starter/catalog.json` -- Planning cases JSON: `/Users/inmanx/Documents/zhinian-creation-assistant/runtime/nianxx-play/content/planning-cases.json` +- App paths manifest: `/Users/inmanx/Documents/zhinian-creation-assistant/removed extracted runtime/.next/server/app-paths-manifest.json` +- Compiled projects API: `/Users/inmanx/Documents/zhinian-creation-assistant/removed extracted runtime/.next/server/app/api/projects/route.js` +- Compiled upload API: `/Users/inmanx/Documents/zhinian-creation-assistant/removed extracted runtime/.next/server/app/api/assets/upload/route.js` +- Compiled prompt assembly API: `/Users/inmanx/Documents/zhinian-creation-assistant/removed extracted runtime/.next/server/app/api/prompt/assemble/route.js` +- Compiled generation polling API: `/Users/inmanx/Documents/zhinian-creation-assistant/removed extracted runtime/.next/server/app/api/generations/[id]/route.js` +- Compiled generation retry API: `/Users/inmanx/Documents/zhinian-creation-assistant/removed extracted runtime/.next/server/app/api/generations/[id]/retry/route.js` +- Creation modes JSON: `/Users/inmanx/Documents/zhinian-creation-assistant/removed extracted runtime/content/seedance-starter/creation-modes.json` +- Starter catalog JSON: `/Users/inmanx/Documents/zhinian-creation-assistant/removed extracted runtime/content/seedance-starter/catalog.json` +- Planning cases JSON: `/Users/inmanx/Documents/zhinian-creation-assistant/removed extracted runtime/content/removed planning case manifest.json` - Runtime local state file: `/Users/inmanx/Documents/zhinian-creation-assistant/.runtime/data/app-state.json` ## Visual/Browser Findings @@ -88,7 +88,7 @@ - The header logo loads from `public/logo/zhinian-logo.png`; after the final branding pass it has no border, background, or box shadow. ## 2026-05-29 UI/UX and Branding Findings -- The current source app is now a Web app in the repository root, not the old `runtime/nianxx-play` standalone-only flow described in the earliest findings. +- The current source app is now a Web app in the repository root, not the old `removed extracted runtime` standalone-only flow described in the earliest findings. - GSAP is used through `lib/ui/motion.ts` rather than directly sprinkled across components. - The UI direction is a professional creation workspace, not a marketing landing page. - Visible English eyebrows/descriptions were removed from module headers per user preference. @@ -111,3 +111,24 @@ - For localhost verification, continue using `curl --noproxy '*'` because local proxy settings can interfere with direct checks. - Before running `npm run build`, stop the dev server (`screen -S zhinian-dev-ui -X quit` and `pkill -f 'next dev --hostname 127.0.0.1 --port 3000'`) to avoid stale Next dev chunk issues. - After build verification, restart the dev server in screen session `zhinian-dev-ui` on `127.0.0.1:3000`. + +## 2026-05-29 Deployment Findings +- Server one-command deployment is now `bash scripts/deploy.sh`. +- Docker Compose service name is `zhinian-aigc`. +- Docker Compose defaults to exposing host port `3000`; set `APP_PORT` in `.env.local` or shell to change it. +- `NEXT_PUBLIC_APP_URL` should be set to the public domain or server URL in production so generated local file URLs are correct. +- Persistent runtime data is bind-mounted through `./.runtime:/app/.runtime`; this folder should be backed up on real servers. +- `.env.local` is intentionally used as the compose `env_file` and remains ignored by Git. +- Current local machine does not have the Docker CLI available, so Docker build was not run here; script syntax, app tests, production build, and local health were verified instead. + +## 2026-05-29 Public API and Task Management Findings +- User confirmed multi-task support should be task management logic, not an external message queue. +- Public API v1 now uses `ZHINIAN_API_KEYS`, supporting `Authorization: Bearer ` and `X-Zhinian-Api-Key`. +- Task creation and provider execution are now split: submit routes enqueue `GenerationJob` records; Worker ticks claim and process jobs. +- `generation_jobs` now carries external client, idempotency, priority, attempts, lock, schedule, timing, and webhook fields. +- Supabase/Postgres production mode expects the `claim_generation_jobs` function from `supabase/schema.sql` for atomic task claiming. +- Local JSON mode serializes task claiming through the existing local write queue and is intended for single-instance development. +- Worker execution can run as `npm run worker`, `npm run worker:once`, or the `zhinian-worker` Docker Compose service. +- Internal Worker processing goes through `/api/internal/worker/tick` protected by `ZHINIAN_INTERNAL_WORKER_TOKEN` in production. +- API v1 routes are `/api/v1/capabilities`, `/api/v1/assets`, `/api/v1/jobs`, `/api/v1/jobs/:id`, `/api/v1/jobs/:id/cancel`, and `/api/v1/openapi.json`. +- Local verification created a public API job and processed it to `succeeded` through `npm run worker:once` in mock mode. diff --git a/lib/content/seedance-starter/catalog.json b/lib/content/seedance-starter/catalog.json new file mode 100644 index 0000000..5eea66d --- /dev/null +++ b/lib/content/seedance-starter/catalog.json @@ -0,0 +1,708 @@ +{ + "generatedAt": "2026-05-03T13:52:55.451Z", + "purpose": "Startup reference content for Seedance-based creation modes. The UI should present these as selectable examples before custom upload.", + "sourceAttribution": { + "repository": "EvoLinkAI/awesome-seedance-2-guide", + "repositoryUrl": "https://github.com/EvoLinkAI/awesome-seedance-2-guide", + "importedPages": [ + "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md", + "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md" + ] + }, + "downloadFailures": [], + "cases": [ + { + "id": "promo-storefront-sample-1", + "slug": "promo-storefront-sample-1", + "mode": "storefront_avatar_storyboard", + "modeLabel": "宣传片制作", + "guideId": "local-promo-sample", + "title": "宣传片制作", + "inputSummary": "本地样片参考 + 店铺分镜素材", + "prompt": "参考@视频1的真实质感、空间氛围和转场节奏,结合用户上传素材,生成干净自然的通用宣传片。", + "promptPattern": { + "primaryReference": "local_storefront_reference_video", + "userControlledInputs": [ + "project_name", + "uploaded_materials", + "final_prompt" + ], + "seedanceInstruction": "将参考视频的真实质感、转场节奏和环境氛围迁移到用户上传素材上。" + }, + "interactionHooks": { + "editorType": "storyboard_cards", + "defaultUserAction": "选择参考样片后,逐段上传店铺分镜素材并修改口播。", + "visibleControls": [ + "分镜素材", + "口播", + "画面辅助", + "镜头辅助" + ], + "customUploadSecondary": false + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": false, + "selectableAsReferenceTemplate": true + }, + "assets": [], + "source": { + "title": "又见乌江名宿宣传片样片", + "page": "local-desktop" + } + }, + { + "id": "promo-digital-human-host-1", + "slug": "promo-digital-human-host-1", + "mode": "storefront_avatar_storyboard", + "modeLabel": "宣传片制作", + "guideId": "local-digital-human-host", + "title": "达人模式", + "inputSummary": "固定达人形象 + 店铺分镜素材", + "prompt": "固定@图片1作为达人出镜形象,结合用户上传的店铺分镜素材,生成达人自然出镜讲解的本地商铺介绍视频。", + "promptPattern": { + "primaryReference": "fixed_digital_human_host", + "userControlledInputs": [ + "storefront_images", + "host_lines", + "scene_order" + ], + "seedanceInstruction": "保持@图片1达人形象稳定,让达人出镜讲解与店铺画面自然穿插,口播、口型和声音同步。" + }, + "interactionHooks": { + "editorType": "storyboard_cards", + "defaultUserAction": "选择达人模式后,逐段上传店铺分镜素材并修改口播。", + "visibleControls": [ + "固定达人", + "分镜素材", + "口播", + "画面辅助", + "镜头辅助" + ], + "customUploadSecondary": false + }, + "display": { + "hasReferenceVideo": false, + "hasResultVideo": false, + "selectableAsReferenceTemplate": true + }, + "assets": [], + "source": { + "title": "本地达人形象", + "page": "bundled-starter" + } + }, + { + "id": "2-3-9-1", + "slug": "2-3-9-1", + "mode": "music_sync_ad", + "modeLabel": "音乐卡点广告片", + "guideId": "09-music-sync", + "title": "时尚换装卡点", + "inputSummary": "4张图 + 1个参考视频(节奏)", + "prompt": "海报中的女生在不停的换装,服装参考@图片1@图片2的样式,手中提着@图片3的包,\n视频节奏参考@视频", + "promptPattern": { + "primaryReference": "reference_video_as_rhythm", + "userControlledInputs": [ + "ordered_images", + "style_intensity", + "scene_crop_freedom" + ], + "seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。", + "reusablePromptFragments": [ + "视频节奏参考@视频1", + "根据@视频1中的画面关键帧的位置和整体节奏进行卡点", + "可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化" + ] + }, + "interactionHooks": { + "editorType": "rhythm_timeline", + "defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。", + "visibleControls": [ + "参考节奏", + "素材出现顺序", + "卡点强度", + "景别自由度", + "整体风格" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": true, + "selectableAsReferenceTemplate": true + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/1", + "assets": [], + "source": { + "title": "音乐卡点", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md" + } + }, + { + "id": "2-3-9-2", + "slug": "2-3-9-2", + "mode": "music_sync_ad", + "modeLabel": "音乐卡点广告片", + "guideId": "09-music-sync", + "title": "多风格图片卡点混剪", + "inputSummary": "6张风格图 + 1个参考视频(节奏)", + "prompt": "@图片1@图片2@图片3@图片4@图片5@图片6@图片7中的图片根据@视频中的画面关键帧的位置\n和整体节奏进行卡点,画面中的人物更有动感,整体画面风格更梦幻,画面张力强,可根据\n音乐及画面需求自行改变参考图的景别,及补充画面的光影变化", + "promptPattern": { + "primaryReference": "reference_video_as_rhythm", + "userControlledInputs": [ + "ordered_images", + "style_intensity", + "scene_crop_freedom" + ], + "seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。", + "reusablePromptFragments": [ + "视频节奏参考@视频1", + "根据@视频1中的画面关键帧的位置和整体节奏进行卡点", + "可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化" + ] + }, + "interactionHooks": { + "editorType": "rhythm_timeline", + "defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。", + "visibleControls": [ + "参考节奏", + "素材出现顺序", + "卡点强度", + "景别自由度", + "整体风格" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": true, + "selectableAsReferenceTemplate": true + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2", + "assets": [], + "source": { + "title": "音乐卡点", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md" + } + }, + { + "id": "2-3-9-3", + "slug": "2-3-9-3", + "mode": "music_sync_ad", + "modeLabel": "音乐卡点广告片", + "guideId": "09-music-sync", + "title": "风光大片卡点转场", + "inputSummary": "6张风景图 + 1个参考视频(节奏)", + "prompt": "@图片1@图片2@图片3@图片4@图片5@图片6的风光场景图,参考@视频中的画面节奏,\n转场间画面风格及音乐节奏进行卡点", + "promptPattern": { + "primaryReference": "reference_video_as_rhythm", + "userControlledInputs": [ + "ordered_images", + "style_intensity", + "scene_crop_freedom" + ], + "seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。", + "reusablePromptFragments": [ + "视频节奏参考@视频1", + "根据@视频1中的画面关键帧的位置和整体节奏进行卡点", + "可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化" + ] + }, + "interactionHooks": { + "editorType": "rhythm_timeline", + "defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。", + "visibleControls": [ + "参考节奏", + "素材出现顺序", + "卡点强度", + "景别自由度", + "整体风格" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": true, + "selectableAsReferenceTemplate": true + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3", + "assets": [], + "source": { + "title": "音乐卡点", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md" + } + }, + { + "id": "2-3-9-4", + "slug": "2-3-9-4", + "mode": "music_sync_ad", + "modeLabel": "音乐卡点广告片", + "guideId": "09-music-sync", + "title": "动漫分镜 + 战斗卡点", + "inputSummary": "纯文本(详细分镜脚本)", + "prompt": "8秒智性博弈式战斗动漫片段,贴合复仇主题。\n0-3秒:女主转身坐下,转镜头,女主下了一步棋子,并说\"你输了\"。\n3-4秒:快速摇镜头,转向对面男人面部特写,男人咬牙切齿,对结果很不满。\n4-6秒:切镜头,俯拍,女人下了一步棋,对面的人们惊叹。\n6-8秒:镜头迅速向下摇,画面黑屏转场,后画面渐亮,昏暗室内,女人看着窗外月色静静地说\n\"我们走着瞧\"。", + "promptPattern": { + "primaryReference": "reference_video_as_rhythm", + "userControlledInputs": [ + "ordered_images", + "style_intensity", + "scene_crop_freedom" + ], + "seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。", + "reusablePromptFragments": [ + "视频节奏参考@视频1", + "根据@视频1中的画面关键帧的位置和整体节奏进行卡点", + "可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化" + ] + }, + "interactionHooks": { + "editorType": "rhythm_timeline", + "defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。", + "visibleControls": [ + "参考节奏", + "素材出现顺序", + "卡点强度", + "景别自由度", + "整体风格" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": false, + "hasResultVideo": true, + "selectableAsReferenceTemplate": false + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/4", + "assets": [], + "source": { + "title": "音乐卡点", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md" + } + }, + { + "id": "2-3-3-1", + "slug": "2-3-3-1", + "mode": "creative_remix", + "modeLabel": "创意视频复刻", + "guideId": "03-creative-effects", + "title": "科幻眼镜穿越多世界", + "inputSummary": "4张场景图 + 1个参考视频", + "prompt": "将@视频1的人物换成@图片1,@图片1为首帧,人物带上虚拟科幻眼镜,参考@视频1的运镜,\n及近的环绕镜头,从第三人称视角变成人物的主观视角,在AI虚拟眼镜中穿梭,来到@图片2\n的深邃的蓝色宇宙,出现几架飞���穿梭向远方,镜头跟随飞船穿梭到@图片3的像素世界,\n镜头低空飞过像素的山林世界,里面的树木生长形式出现,随后视角仰拍,急速穿梭到\n@图片4的浅绿色纹理的星球,镜头穿梭并掠过星球表面", + "promptPattern": { + "primaryReference": "reference_video_as_effect_template", + "userControlledInputs": [ + "replacement_subject", + "replacement_scene", + "text_or_logo_replacement", + "preserve_motion", + "preserve_effect", + "preserve_rhythm" + ], + "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", + "reusablePromptFragments": [ + "完全参考@视频1的特效和动作", + "参考@视频1的运镜", + "将@视频1的首帧人物替换成@图片1", + "文字替换成用户提供的品牌文案或Logo" + ] + }, + "interactionHooks": { + "editorType": "reference_mapping", + "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", + "visibleControls": [ + "保留运镜", + "保留动作", + "保留特效", + "保留节奏", + "替换主体", + "替换场景", + "替换文字/Logo" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": true, + "selectableAsReferenceTemplate": true + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/1", + "assets": [], + "source": { + "title": "创意模板 / 复杂特效精准复刻", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md" + } + }, + { + "id": "2-3-3-2", + "slug": "2-3-3-2", + "mode": "creative_remix", + "modeLabel": "创意视频复刻", + "guideId": "03-creative-effects", + "title": "鱼眼换装闪切", + "inputSummary": "6张图(人物+服装)+ 1个参考视频", + "prompt": "参考第一张图片里模特的五官长相。模特分别穿着第2-6张参考图里的服装凑近镜头,\n做出调皮、冷酷、可爱、惊讶、耍帅的造型,每一个造型穿着不同服装,每次更换,\n画面伴随会切镜,参考视频的里鱼眼镜头效果、重影闪烁的炫影画面效果,参考@视频1", + "promptPattern": { + "primaryReference": "reference_video_as_effect_template", + "userControlledInputs": [ + "replacement_subject", + "replacement_scene", + "text_or_logo_replacement", + "preserve_motion", + "preserve_effect", + "preserve_rhythm" + ], + "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", + "reusablePromptFragments": [ + "完全参考@视频1的特效和动作", + "参考@视频1的运镜", + "将@视频1的首帧人物替换成@图片1", + "文字替换成用户提供的品牌文案或Logo" + ] + }, + "interactionHooks": { + "editorType": "reference_mapping", + "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", + "visibleControls": [ + "保留运镜", + "保留动作", + "保留特效", + "保留节奏", + "替换主体", + "替换场景", + "替换文字/Logo" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": true, + "selectableAsReferenceTemplate": true + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2", + "assets": [], + "source": { + "title": "创意模板 / 复杂特效精准复刻", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md" + } + }, + { + "id": "2-3-3-3", + "slug": "2-3-3-3", + "mode": "creative_remix", + "modeLabel": "创意视频复刻", + "guideId": "03-creative-effects", + "title": "羽绒服广告创意复刻", + "inputSummary": "3张图 + 1个参考视频", + "prompt": "参考视频的广告创意,用提供的羽绒服图片,并参考鹅绒图片、天鹅图片,搭配以下广告词\n\"这是根鹅绒,这是暖天鹅,这是能穿的极地天鹅绒羽绒服,新年穿得暖,生活过得暖\",\n生成新的羽绒服广告视频。", + "promptPattern": { + "primaryReference": "reference_video_as_effect_template", + "userControlledInputs": [ + "replacement_subject", + "replacement_scene", + "text_or_logo_replacement", + "preserve_motion", + "preserve_effect", + "preserve_rhythm" + ], + "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", + "reusablePromptFragments": [ + "完全参考@视频1的特效和动作", + "参考@视频1的运镜", + "将@视频1的首帧人物替换成@图片1", + "文字替换成用户提供的品牌文案或Logo" + ] + }, + "interactionHooks": { + "editorType": "reference_mapping", + "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", + "visibleControls": [ + "保留运镜", + "保留动作", + "保留特效", + "保留节奏", + "替换主体", + "替换场景", + "替换文字/Logo" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": true, + "selectableAsReferenceTemplate": true + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/3", + "assets": [], + "source": { + "title": "创意模板 / 复杂特效精准复刻", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md" + } + }, + { + "id": "2-3-3-4", + "slug": "2-3-3-4", + "mode": "creative_remix", + "modeLabel": "创意视频复刻", + "guideId": "03-creative-effects", + "title": "水墨太极功夫", + "inputSummary": "1张人物图 + 1个参考视频", + "prompt": "黑白水墨风格,@图片1的人物参考@视频1的特效和动作,上演一段水墨太极功夫", + "promptPattern": { + "primaryReference": "reference_video_as_effect_template", + "userControlledInputs": [ + "replacement_subject", + "replacement_scene", + "text_or_logo_replacement", + "preserve_motion", + "preserve_effect", + "preserve_rhythm" + ], + "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", + "reusablePromptFragments": [ + "完全参考@视频1的特效和动作", + "参考@视频1的运镜", + "将@视频1的首帧人物替换成@图片1", + "文字替换成用户提供的品牌文案或Logo" + ] + }, + "interactionHooks": { + "editorType": "reference_mapping", + "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", + "visibleControls": [ + "保留运镜", + "保留动作", + "保留特效", + "保留节奏", + "替换主体", + "替换场景", + "替换文字/Logo" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": true, + "selectableAsReferenceTemplate": true + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/4", + "assets": [], + "source": { + "title": "创意模板 / 复杂特效精准复刻", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md" + } + }, + { + "id": "2-3-3-5", + "slug": "2-3-3-5", + "mode": "creative_remix", + "modeLabel": "创意视频复刻", + "guideId": "03-creative-effects", + "title": "角色变装特效(玫瑰蔓延)", + "inputSummary": "2张人物图 + 1个参考视频", + "prompt": "将@视频1的首帧人物替换成@图片1,完全@参考视频1的特效和动作,手里的花蕊长出玫瑰\n花瓣,裂纹在脸部向上延伸,逐渐被杂草覆盖,人物双手拂过脸部,杂草变成粒子消散,\n最后变成@图片2的长相", + "promptPattern": { + "primaryReference": "reference_video_as_effect_template", + "userControlledInputs": [ + "replacement_subject", + "replacement_scene", + "text_or_logo_replacement", + "preserve_motion", + "preserve_effect", + "preserve_rhythm" + ], + "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", + "reusablePromptFragments": [ + "完全参考@视频1的特效和动作", + "参考@视频1的运镜", + "将@视频1的首帧人物替换成@图片1", + "文字替换成用户提供的品牌文案或Logo" + ] + }, + "interactionHooks": { + "editorType": "reference_mapping", + "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", + "visibleControls": [ + "保留运镜", + "保留动作", + "保留特效", + "保留节奏", + "替换主体", + "替换场景", + "替换文字/Logo" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": true, + "selectableAsReferenceTemplate": true + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/5", + "assets": [], + "source": { + "title": "创意模板 / 复杂特效精准复刻", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md" + } + }, + { + "id": "2-3-3-6", + "slug": "2-3-3-6", + "mode": "creative_remix", + "modeLabel": "创意视频复刻", + "guideId": "03-creative-effects", + "title": "拼图破碎转场 + 文字替换", + "inputSummary": "2张图 + 1个参考视频", + "prompt": "由@图片1的天花板开始,参考@视频1的拼图破碎效果进行转场,\"BELIEVE\"字体替换成\n\"Seedance\",参考@图2的字体", + "promptPattern": { + "primaryReference": "reference_video_as_effect_template", + "userControlledInputs": [ + "replacement_subject", + "replacement_scene", + "text_or_logo_replacement", + "preserve_motion", + "preserve_effect", + "preserve_rhythm" + ], + "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", + "reusablePromptFragments": [ + "完全参考@视频1的特效和动作", + "参考@视频1的运镜", + "将@视频1的首帧人物替换成@图片1", + "文字替换成用户提供的品牌文案或Logo" + ] + }, + "interactionHooks": { + "editorType": "reference_mapping", + "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", + "visibleControls": [ + "保留运镜", + "保留动作", + "保留特效", + "保留节奏", + "替换主体", + "替换场景", + "替换文字/Logo" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": true, + "selectableAsReferenceTemplate": true + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/6", + "assets": [], + "source": { + "title": "创意模板 / 复杂特效精准复刻", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md" + } + }, + { + "id": "2-3-3-7", + "slug": "2-3-3-7", + "mode": "creative_remix", + "modeLabel": "创意视频复刻", + "guideId": "03-creative-effects", + "title": "金色粒子片头", + "inputSummary": "1张文字/Logo图 + 1个参考视频", + "prompt": "以黑幕开场,参考视频1的粒子特效和材质,金色鎏金材质的沙砾从画面左边飘出并向右覆盖,\n参考@视频1的粒子吹散效果,@图片1的字体逐渐出现在画面中心", + "promptPattern": { + "primaryReference": "reference_video_as_effect_template", + "userControlledInputs": [ + "replacement_subject", + "replacement_scene", + "text_or_logo_replacement", + "preserve_motion", + "preserve_effect", + "preserve_rhythm" + ], + "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", + "reusablePromptFragments": [ + "完全参考@视频1的特效和动作", + "参考@视频1的运镜", + "将@视频1的首帧人物替换成@图片1", + "文字替换成用户提供的品牌文案或Logo" + ] + }, + "interactionHooks": { + "editorType": "reference_mapping", + "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", + "visibleControls": [ + "保留运镜", + "保留动作", + "保留特效", + "保留节奏", + "替换主体", + "替换场景", + "替换文字/Logo" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": true, + "selectableAsReferenceTemplate": true + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/7", + "assets": [], + "source": { + "title": "创意模板 / 复杂特效精准复刻", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md" + } + }, + { + "id": "2-3-3-8", + "slug": "2-3-3-8", + "mode": "creative_remix", + "modeLabel": "创意视频复刻", + "guideId": "03-creative-effects", + "title": "吃泡面抽象行为艺术", + "inputSummary": "1张人物图 + 1个参考视频", + "prompt": "@图片1的人物参考@视频1中的动作和表情变化,展示吃泡面的抽象行为", + "promptPattern": { + "primaryReference": "reference_video_as_effect_template", + "userControlledInputs": [ + "replacement_subject", + "replacement_scene", + "text_or_logo_replacement", + "preserve_motion", + "preserve_effect", + "preserve_rhythm" + ], + "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", + "reusablePromptFragments": [ + "完全参考@视频1的特效和动作", + "参考@视频1的运镜", + "将@视频1的首帧人物替换成@图片1", + "文字替换成用户提供的品牌文案或Logo" + ] + }, + "interactionHooks": { + "editorType": "reference_mapping", + "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", + "visibleControls": [ + "保留运镜", + "保留动作", + "保留特效", + "保留节奏", + "替换主体", + "替换场景", + "替换文字/Logo" + ], + "customUploadSecondary": true + }, + "display": { + "hasReferenceVideo": true, + "hasResultVideo": true, + "selectableAsReferenceTemplate": true + }, + "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/8", + "assets": [], + "source": { + "title": "创意模板 / 复杂特效精准复刻", + "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md" + } + } + ] +} diff --git a/lib/content/video-templates.ts b/lib/content/video-templates.ts index 180ac62..4d77b48 100644 --- a/lib/content/video-templates.ts +++ b/lib/content/video-templates.ts @@ -1,7 +1,7 @@ -import catalog from "@/runtime/nianxx-play/content/seedance-starter/catalog.json"; +import catalog from "@/lib/content/seedance-starter/catalog.json"; -type LegacyCatalog = typeof catalog; -type LegacyCase = LegacyCatalog["cases"][number]; +type SeedanceCatalog = typeof catalog; +type SeedanceCase = SeedanceCatalog["cases"][number]; export type VideoTemplate = { id: string; @@ -24,26 +24,16 @@ export type VideoTemplate = { }; export function getVideoTemplates(): VideoTemplate[] { - return (catalog.cases as LegacyCase[]).map((item) => ({ + return (catalog.cases as SeedanceCase[]).map((item) => ({ id: item.id, title: item.title, mode: item.mode, modeLabel: item.modeLabel, prompt: item.prompt, seedanceInstruction: typeof item.promptPattern?.seedanceInstruction === "string" ? item.promptPattern.seedanceInstruction : undefined, - coverUrl: rewriteLegacyUrl(item.display?.coverPublicUrl), - referenceVideoUrl: rewriteLegacyUrl(item.display?.referenceVideoPublicUrl), - resultVideoUrl: rewriteLegacyUrl(item.display?.resultVideoPublicUrl), selectable: Boolean(item.display?.selectableAsReferenceTemplate), controls: item.interactionHooks?.visibleControls || [], - materials: (item.assets || []) - .map((asset) => ({ - role: asset.role, - type: asset.role.includes("video") ? "video" as const : asset.role.includes("audio") ? "audio" as const : "image" as const, - url: rewriteLegacyUrl(asset.publicUrl) || "", - label: "promptLabel" in asset ? asset.promptLabel : undefined - })) - .filter((asset) => asset.url) + materials: [] })); } @@ -51,10 +41,3 @@ export function getTemplateById(id?: string): VideoTemplate | undefined { if (!id) return undefined; return getVideoTemplates().find((template) => template.id === id); } - -function rewriteLegacyUrl(url?: string | null): string | undefined { - if (!url) return undefined; - if (/^https?:\/\//i.test(url)) return url; - if (url.startsWith("/seedance-starter-assets/") || url.startsWith("/starter/") || url.startsWith("/planning-cases/")) return url; - return url; -} diff --git a/lib/server/data-store.ts b/lib/server/data-store.ts index ec6035e..647e7bf 100644 --- a/lib/server/data-store.ts +++ b/lib/server/data-store.ts @@ -1,7 +1,7 @@ import { readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createClient, type SupabaseClient } from "@supabase/supabase-js"; -import type { AppState, Asset, GenerationJob, Project, UsageEvent } from "@/lib/types"; +import type { AppState, Asset, GenerationCapability, GenerationJob, GenerationStatus, Project, UsageEvent } from "@/lib/types"; import { createId } from "@/lib/server/ids"; import { dataDir, DEFAULT_OWNER_ID, ensureRuntimeDirs } from "@/lib/server/runtime"; @@ -12,6 +12,21 @@ type AssetInput = Omit & Partial & Partial>; type UsageInput = Omit & Partial>; +export type GenerationJobListFilters = { + ownerId?: string; + externalClientId?: string; + status?: GenerationStatus; + capability?: GenerationCapability; + limit?: number; + before?: string; +}; + +export type ClaimGenerationJobsInput = { + workerId: string; + limit?: number; + lockTimeoutMs?: number; +}; + export async function listAssets(ownerId = DEFAULT_OWNER_ID): Promise { const supabase = getSupabaseAdmin(); if (supabase) { @@ -82,19 +97,37 @@ export async function deleteAsset(id: string): Promise { } export async function listGenerationJobs(ownerId = DEFAULT_OWNER_ID, limit = 200): Promise { + return listGenerationJobsFiltered({ ownerId, limit }); +} + +export async function listGenerationJobsFiltered(filters: GenerationJobListFilters = {}): Promise { + const ownerId = filters.ownerId || DEFAULT_OWNER_ID; + const limit = filters.limit || 200; const supabase = getSupabaseAdmin(); if (supabase) { - const { data, error } = await supabase + let query = supabase .from("generation_jobs") .select("*") .eq("owner_id", ownerId) .order("created_at", { ascending: false }) .limit(limit); + if (filters.externalClientId) query = query.eq("external_client_id", filters.externalClientId); + if (filters.status) query = query.eq("status", filters.status); + if (filters.capability) query = query.eq("capability", filters.capability); + if (filters.before) query = query.lt("created_at", filters.before); + const { data, error } = await query; if (error) throw new Error(error.message); return (data || []).map(jobFromRow); } const state = await readState(); - return state.generationJobs.filter((job) => job.ownerId === ownerId).sort(sortNewest).slice(0, limit); + return state.generationJobs + .filter((job) => job.ownerId === ownerId) + .filter((job) => !filters.externalClientId || job.externalClientId === filters.externalClientId) + .filter((job) => !filters.status || job.status === filters.status) + .filter((job) => !filters.capability || job.capability === filters.capability) + .filter((job) => !filters.before || job.createdAt < filters.before) + .sort(sortNewest) + .slice(0, limit); } export async function getGenerationJob(id: string): Promise { @@ -118,6 +151,11 @@ export async function createGenerationJob(input: JobInput): Promise { + const supabase = getSupabaseAdmin(); + if (supabase) { + const { data, error } = await supabase + .from("generation_jobs") + .select("*") + .eq("owner_id", ownerId) + .eq("external_client_id", externalClientId) + .eq("idempotency_key", idempotencyKey) + .maybeSingle(); + if (error) throw new Error(error.message); + return data ? jobFromRow(data) : null; + } + const state = await readState(); + return state.generationJobs.find((job) => ( + job.ownerId === ownerId && + job.externalClientId === externalClientId && + job.idempotencyKey === idempotencyKey + )) || null; +} + +export async function claimGenerationJobs(input: ClaimGenerationJobsInput): Promise { + const limit = Math.max(1, Math.min(input.limit || 1, 20)); + const lockTimeoutMs = input.lockTimeoutMs ?? 5 * 60 * 1000; + const supabase = getSupabaseAdmin(); + if (supabase) { + const { data, error } = await supabase.rpc("claim_generation_jobs", { + p_worker_id: input.workerId, + p_limit: limit, + p_lock_timeout_seconds: Math.ceil(lockTimeoutMs / 1000) + }); + if (error) throw new Error(`claim_generation_jobs failed: ${error.message}`); + return (Array.isArray(data) ? data : []).map(jobFromRow); + } + + return mutateLocalState((state) => { + const now = new Date(); + const nowIso = now.toISOString(); + const staleBefore = new Date(now.getTime() - lockTimeoutMs).toISOString(); + const selected = state.generationJobs + .filter((job) => isClaimableJob(job, nowIso, staleBefore)) + .sort(sortClaimableJobs) + .slice(0, limit); + for (const job of selected) { + job.lockedAt = nowIso; + job.lockedBy = input.workerId; + if (!job.startedAt) job.startedAt = nowIso; + job.updatedAt = nowIso; + } + return selected.map((job) => ({ ...job })); + }); +} + +export async function clearGenerationJobLock( + id: string, + patch: Partial = {}, + options: { clearProviderTaskId?: boolean } = {} +): Promise { + const updatedAt = new Date().toISOString(); + const supabase = getSupabaseAdmin(); + if (supabase) { + const { data, error } = await supabase + .from("generation_jobs") + .update({ + ...jobToRow({ ...patch, updatedAt } as GenerationJob), + locked_at: null, + locked_by: null, + ...(options.clearProviderTaskId ? { provider_task_id: null } : {}) + }) + .eq("id", id) + .select("*") + .single(); + if (error) throw new Error(error.message); + return jobFromRow(data); + } + return mutateLocalState((state) => { + const index = state.generationJobs.findIndex((job) => job.id === id); + if (index === -1) throw new Error(`Generation job not found: ${id}`); + state.generationJobs[index] = { + ...state.generationJobs[index], + ...patch, + lockedAt: undefined, + lockedBy: undefined, + ...(options.clearProviderTaskId ? { providerTaskId: undefined } : {}), + updatedAt + }; + return state.generationJobs[index]; + }); +} + export async function updateGenerationJob(id: string, patch: Partial): Promise { const updatedAt = new Date().toISOString(); const supabase = getSupabaseAdmin(); @@ -252,6 +384,20 @@ function sortNewest(a: T, b: T): number { return b.createdAt.localeCompare(a.createdAt); } +function isClaimableJob(job: GenerationJob, nowIso: string, staleBefore: string): boolean { + if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return false; + if ((job.scheduledAt || job.createdAt) > nowIso) return false; + return !job.lockedAt || job.lockedAt < staleBefore; +} + +function sortClaimableJobs(a: GenerationJob, b: GenerationJob): number { + const priority = (b.priority || 0) - (a.priority || 0); + if (priority !== 0) return priority; + const scheduled = (a.scheduledAt || a.createdAt).localeCompare(b.scheduledAt || b.createdAt); + if (scheduled !== 0) return scheduled; + return a.createdAt.localeCompare(b.createdAt); +} + function assetToRow(asset: Partial) { return { id: asset.id, @@ -288,6 +434,7 @@ function jobToRow(job: Partial) { const row: Record = {}; if (job.id !== undefined) row.id = job.id; if (job.ownerId !== undefined) row.owner_id = job.ownerId; + if (job.externalClientId !== undefined) row.external_client_id = job.externalClientId; if (job.capability !== undefined) row.capability = job.capability; if (job.provider !== undefined) row.provider = job.provider; if (job.reqKey !== undefined) row.req_key = job.reqKey; @@ -301,6 +448,19 @@ function jobToRow(job: Partial) { if (job.responsePayload !== undefined) row.response_payload = job.responsePayload; if (job.error !== undefined) row.error = job.error; if (job.retryOf !== undefined) row.retry_of = job.retryOf; + if (job.idempotencyKey !== undefined) row.idempotency_key = job.idempotencyKey; + if (job.idempotencyFingerprint !== undefined) row.idempotency_fingerprint = job.idempotencyFingerprint; + if (job.priority !== undefined) row.priority = job.priority; + if (job.attempts !== undefined) row.attempts = job.attempts; + if (job.maxAttempts !== undefined) row.max_attempts = job.maxAttempts; + if (job.scheduledAt !== undefined) row.scheduled_at = job.scheduledAt; + if (job.lockedAt !== undefined) row.locked_at = job.lockedAt; + if (job.lockedBy !== undefined) row.locked_by = job.lockedBy; + if (job.startedAt !== undefined) row.started_at = job.startedAt; + if (job.completedAt !== undefined) row.completed_at = job.completedAt; + if (job.webhookUrl !== undefined) row.webhook_url = job.webhookUrl; + if (job.webhookAttempts !== undefined) row.webhook_attempts = job.webhookAttempts; + if (job.webhookLastStatus !== undefined) row.webhook_last_status = job.webhookLastStatus; if (job.createdAt !== undefined) row.created_at = job.createdAt; if (job.updatedAt !== undefined) row.updated_at = job.updatedAt; return row; @@ -310,6 +470,7 @@ function jobFromRow(row: Record): GenerationJob { return { id: String(row.id), ownerId: String(row.owner_id), + externalClientId: optionalString(row.external_client_id), capability: row.capability as GenerationJob["capability"], provider: row.provider as GenerationJob["provider"], reqKey: String(row.req_key), @@ -323,6 +484,27 @@ function jobFromRow(row: Record): GenerationJob { responsePayload: isRecord(row.response_payload) ? row.response_payload : undefined, error: isRecord(row.error) ? { message: String(row.error.message || "Unknown error"), code: row.error.code as string | number | undefined, retryable: Boolean(row.error.retryable) } : undefined, retryOf: row.retry_of ? String(row.retry_of) : undefined, + idempotencyKey: optionalString(row.idempotency_key), + idempotencyFingerprint: optionalString(row.idempotency_fingerprint), + priority: optionalNumber(row.priority), + attempts: optionalNumber(row.attempts), + maxAttempts: optionalNumber(row.max_attempts), + scheduledAt: optionalString(row.scheduled_at), + lockedAt: optionalString(row.locked_at), + lockedBy: optionalString(row.locked_by), + startedAt: optionalString(row.started_at), + completedAt: optionalString(row.completed_at), + webhookUrl: optionalString(row.webhook_url), + webhookAttempts: optionalNumber(row.webhook_attempts), + webhookLastStatus: isRecord(row.webhook_last_status) + ? { + ok: Boolean(row.webhook_last_status.ok), + status: optionalNumber(row.webhook_last_status.status), + error: optionalString(row.webhook_last_status.error), + attemptedAt: String(row.webhook_last_status.attemptedAt || row.webhook_last_status.attempted_at || ""), + nextAttemptAt: optionalString(row.webhook_last_status.nextAttemptAt || row.webhook_last_status.next_attempt_at) + } + : undefined, createdAt: String(row.created_at), updatedAt: String(row.updated_at) }; @@ -355,3 +537,15 @@ function usageFromRow(row: Record): UsageEvent { function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + +function optionalString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function optionalNumber(value: unknown): number | undefined { + if (value === undefined || value === null || value === "") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} diff --git a/lib/server/generation-service.ts b/lib/server/generation-service.ts index e885882..0b21a0c 100644 --- a/lib/server/generation-service.ts +++ b/lib/server/generation-service.ts @@ -30,6 +30,7 @@ import { queryVisualTask, shouldMockVisualApi, submitVisualTask } from "@/lib/vo export type SubmitImageJobInput = { ownerId?: string; + externalClientId?: string; capability: EnabledImageCapability; prompt?: string; imageUrls?: string[]; @@ -43,6 +44,11 @@ export type SubmitImageJobInput = { resolution?: "4k" | "8k"; seed?: number; retryOf?: string; + idempotencyKey?: string; + idempotencyFingerprint?: string; + priority?: number; + maxAttempts?: number; + webhookUrl?: string; }; export async function submitImageJob(input: SubmitImageJobInput, origin: string): Promise { @@ -57,6 +63,7 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string) const reqKey = engine === "evolink" ? getEvolinkImageSettings().model : capability.reqKey; let job = await createGenerationJob({ ownerId, + externalClientId: input.externalClientId, capability: input.capability, provider: mock ? "mock" : engine === "evolink" ? "evolink" : "volcengine-visual", reqKey, @@ -70,15 +77,30 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string) input, providerPayload }, - retryOf: input.retryOf + retryOf: input.retryOf, + idempotencyKey: input.idempotencyKey, + idempotencyFingerprint: input.idempotencyFingerprint, + priority: input.priority, + maxAttempts: input.maxAttempts, + webhookUrl: input.webhookUrl }); - if (mock) { - return completeMockJob(job, origin); - } + return job; +} +export async function advanceImageJob(jobId: string, origin: string): Promise { + const job = await getGenerationJob(jobId); + if (!job) throw new Error(`Generation job not found: ${jobId}`); + if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return job; + if (job.provider === "mock") return completeMockJob(job, origin); + if (!job.providerTaskId) return dispatchImageJob(job); + return syncImageJob(job.id, origin); +} + +async function dispatchImageJob(job: GenerationJob): Promise { + const providerPayload = asRecord(job.requestPayload.providerPayload); try { - if (engine === "evolink") { + if (job.provider === "evolink") { const response = await submitEvolinkImageTask(providerPayload); const taskId = getEvolinkTaskId(response); if (!taskId) { @@ -132,7 +154,7 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string) export async function syncImageJob(jobId: string, origin: string): Promise { const job = await getGenerationJob(jobId); if (!job) throw new Error(`Generation job not found: ${jobId}`); - if (["succeeded", "failed", "expired"].includes(job.status)) return job; + if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return job; if (job.provider === "mock") return completeMockJob(job, origin); if (!job.providerTaskId) return job; @@ -353,3 +375,9 @@ function sourceForCapability(capability: string) { if (capability === "image.upscale") return "upscaled"; return "generated"; } + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} diff --git a/lib/server/public-api-auth.ts b/lib/server/public-api-auth.ts new file mode 100644 index 0000000..c1aec80 --- /dev/null +++ b/lib/server/public-api-auth.ts @@ -0,0 +1,69 @@ +import { timingSafeEqual } from "node:crypto"; + +export type PublicApiClient = { + id: string; + key: string; +}; + +export class PublicApiAuthError extends Error { + status: number; + + constructor(message: string, status = 401) { + super(message); + this.name = "PublicApiAuthError"; + this.status = status; + } +} + +export function getPublicApiClients(): PublicApiClient[] { + const configured = process.env.ZHINIAN_API_KEYS?.trim(); + if (!configured) return []; + return configured + .split(/[\n,]+/) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const separator = entry.indexOf(":"); + if (separator === -1) return { id: "default", key: entry }; + return { + id: entry.slice(0, separator).trim(), + key: entry.slice(separator + 1).trim() + }; + }) + .filter((client) => client.id && client.key); +} + +export function authenticatePublicApiRequest(request: Request): PublicApiClient { + const presented = getPresentedApiKey(request); + if (!presented) throw new PublicApiAuthError("Missing API key."); + const client = getPublicApiClients().find((candidate) => safeEqual(candidate.key, presented)); + if (!client) throw new PublicApiAuthError("Invalid API key."); + return client; +} + +export function assertInternalWorkerToken(request: Request) { + const expected = process.env.ZHINIAN_INTERNAL_WORKER_TOKEN?.trim(); + if (!expected && process.env.NODE_ENV !== "production") return; + if (!expected) throw new PublicApiAuthError("Worker token is not configured.", 500); + const presented = request.headers.get("x-zhinian-worker-token") || bearerToken(request); + if (!presented || !safeEqual(expected, presented)) { + throw new PublicApiAuthError("Invalid worker token.", 401); + } +} + +function getPresentedApiKey(request: Request): string | undefined { + return bearerToken(request) || request.headers.get("x-zhinian-api-key") || undefined; +} + +function bearerToken(request: Request): string | undefined { + const authorization = request.headers.get("authorization") || ""; + const match = authorization.match(/^Bearer\s+(.+)$/i); + return match?.[1]?.trim() || undefined; +} + +function safeEqual(expected: string, presented: string): boolean { + const left = Buffer.from(expected); + const right = Buffer.from(presented); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); +} diff --git a/lib/server/public-api-jobs.ts b/lib/server/public-api-jobs.ts new file mode 100644 index 0000000..2c8e24a --- /dev/null +++ b/lib/server/public-api-jobs.ts @@ -0,0 +1,149 @@ +import { createHash } from "node:crypto"; +import { assemblePrompt, type PromptAssemblyInput, type PromptMaterial } from "@/lib/prompt/assembler"; +import { findGenerationJobByIdempotency } from "@/lib/server/data-store"; +import { submitImageJob, type SubmitImageJobInput } from "@/lib/server/generation-service"; +import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; +import { submitVideoJob, type SubmitVideoJobInput } from "@/lib/server/video-generation-service"; +import type { PublicApiClient } from "@/lib/server/public-api-auth"; +import type { EnabledImageCapability, GenerationCapability, GenerationJob } from "@/lib/types"; + +export class PublicApiConflictError extends Error { + status = 409; + + constructor(message: string) { + super(message); + this.name = "PublicApiConflictError"; + } +} + +export type PublicJobCreateBody = { + capability?: GenerationCapability; + prompt?: string; + inputUrls?: string[]; + imageUrls?: string[]; + inputAssetIds?: string[]; + materials?: PromptMaterial[]; + promptAssembly?: PromptAssemblyInput; + settings?: SubmitVideoJobInput["settings"]; + priority?: number; + webhookUrl?: string; + idempotencyKey?: string; + scale?: number; + width?: number; + height?: number; + min_ratio?: number; + max_ratio?: number; + force_single?: boolean; + resolution?: "4k" | "8k"; + seed?: number; +}; + +export async function createPublicGenerationJob(input: { + client: PublicApiClient; + body: PublicJobCreateBody; + request: Request; + origin: string; +}): Promise<{ job: GenerationJob; reused: boolean }> { + const capability = input.body.capability || "image.generate"; + const idempotencyKey = input.request.headers.get("idempotency-key") || input.body.idempotencyKey; + const fingerprint = idempotencyKey ? fingerprintBody(input.body) : undefined; + if (idempotencyKey && fingerprint) { + const existing = await findGenerationJobByIdempotency(input.client.id, idempotencyKey); + if (existing) { + if (existing.idempotencyFingerprint !== fingerprint) { + throw new PublicApiConflictError("Idempotency key was already used with a different request body."); + } + return { job: existing, reused: true }; + } + } + + const common = { + ownerId: DEFAULT_OWNER_ID, + externalClientId: input.client.id, + idempotencyKey, + idempotencyFingerprint: fingerprint, + priority: normalizePriority(input.body.priority), + webhookUrl: normalizeWebhookUrl(input.body.webhookUrl), + maxAttempts: 3 + }; + + if (capability === "video.generate") { + const job = await submitVideoJob({ + ...input.body, + ...common, + mode: "video", + materials: input.body.materials || input.body.promptAssembly?.materials || [] + } as SubmitVideoJobInput, input.origin); + return { job, reused: false }; + } + + const imageCapability = normalizeImageCapability(capability); + const assembled = input.body.promptAssembly + ? assemblePrompt({ + ...input.body.promptAssembly, + mode: "image", + materials: input.body.materials || input.body.promptAssembly.materials || [] + }) + : undefined; + const materialImages = (input.body.materials || assembled?.materials || []) + .filter((material) => material.type === "image") + .map((material) => material.url); + const job = await submitImageJob({ + ...common, + capability: imageCapability, + prompt: input.body.prompt || assembled?.prompt, + imageUrls: input.body.imageUrls || input.body.inputUrls || materialImages, + inputAssetIds: input.body.inputAssetIds || (input.body.materials || []).map((material) => material.id).filter(Boolean) as string[], + scale: asNumber(input.body.scale), + width: asNumber(input.body.width), + height: asNumber(input.body.height), + min_ratio: asNumber(input.body.min_ratio), + max_ratio: asNumber(input.body.max_ratio), + force_single: Boolean(input.body.force_single), + resolution: input.body.resolution, + seed: asNumber(input.body.seed) + } satisfies SubmitImageJobInput, input.origin); + return { job, reused: false }; +} + +function normalizeImageCapability(capability: GenerationCapability): EnabledImageCapability { + if (capability === "image.generate" || capability === "image.inpaint" || capability === "image.upscale") { + return capability; + } + throw new Error(`Unsupported image capability: ${capability}`); +} + +function normalizePriority(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(-100, Math.min(100, Math.trunc(parsed))); +} + +function normalizeWebhookUrl(value: unknown): string | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + const url = new URL(value.trim()); + if (!["http:", "https:"].includes(url.protocol)) throw new Error("webhookUrl must be an HTTP or HTTPS URL."); + return url.toString(); +} + +function asNumber(value: unknown): number | undefined { + if (value === undefined || value === null || value === "") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function fingerprintBody(body: PublicJobCreateBody): string { + const { idempotencyKey: _idempotencyKey, ...fingerprintSource } = body; + return createHash("sha256").update(stableStringify(fingerprintSource)).digest("hex"); +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/lib/server/public-api-response.ts b/lib/server/public-api-response.ts new file mode 100644 index 0000000..e284da4 --- /dev/null +++ b/lib/server/public-api-response.ts @@ -0,0 +1,9 @@ +import { jsonError } from "@/lib/server/api"; +import { PublicApiAuthError } from "@/lib/server/public-api-auth"; +import { PublicApiConflictError } from "@/lib/server/public-api-jobs"; + +export function publicApiError(error: unknown) { + if (error instanceof PublicApiAuthError) return jsonError(error.message, error.status); + if (error instanceof PublicApiConflictError) return jsonError(error.message, error.status); + return jsonError(error); +} diff --git a/lib/server/runtime.ts b/lib/server/runtime.ts index ee455ac..987b342 100644 --- a/lib/server/runtime.ts +++ b/lib/server/runtime.ts @@ -8,19 +8,19 @@ export function rootDir(): string { } export function runtimeDir(): string { - return process.env.NIANXXPLAY_RUNTIME_DIR || join(rootDir(), ".runtime"); + return process.env.ZHINIAN_RUNTIME_DIR || join(rootDir(), ".runtime"); } export function dataDir(): string { - return process.env.NIANXXPLAY_DATA_DIR || join(runtimeDir(), "data"); + return process.env.ZHINIAN_DATA_DIR || join(runtimeDir(), "data"); } export function uploadDir(): string { - return process.env.NIANXXPLAY_UPLOAD_DIR || join(runtimeDir(), "uploads"); + return process.env.ZHINIAN_UPLOAD_DIR || join(runtimeDir(), "uploads"); } export function resultDir(): string { - return process.env.NIANXXPLAY_RESULT_DIR || join(runtimeDir(), "generated-results"); + return process.env.ZHINIAN_RESULT_DIR || join(runtimeDir(), "generated-results"); } export async function ensureRuntimeDirs(): Promise { @@ -36,7 +36,7 @@ export function localRuntimePath(...parts: string[]): string { } export function requestOrigin(request: Request): string { - const configured = process.env.NEXT_PUBLIC_APP_URL || process.env.NIANXXPLAY_PUBLIC_BASE_URL; + const configured = process.env.NEXT_PUBLIC_APP_URL || process.env.ZHINIAN_PUBLIC_BASE_URL; if (configured) return normalizePublicOrigin(configured); return normalizePublicOrigin(new URL(request.url).origin); } diff --git a/lib/server/storage.ts b/lib/server/storage.ts index 8af32b0..f685a5e 100644 --- a/lib/server/storage.ts +++ b/lib/server/storage.ts @@ -181,19 +181,20 @@ export async function readLocalServedFile(area: "uploads" | "generated-results", } } -export async function readLegacyPublicFile(pathParts: string[]): Promise<{ +export async function readAssetForDownload(asset: Asset): Promise<{ bytes: Buffer; contentType: string; } | null> { - const filePath = join(process.cwd(), "runtime", "nianxx-play", "public", ...pathParts); - try { - return { - bytes: await readFile(filePath), - contentType: contentTypeForPath(filePath) - }; - } catch { - return null; - } + const local = await readLocalAsset(asset); + if (local) return local; + + if (!/^https?:\/\//i.test(asset.url)) return null; + const response = await fetch(asset.url); + if (!response.ok) return null; + return { + bytes: Buffer.from(await response.arrayBuffer()), + contentType: response.headers.get("content-type") || contentTypeForPath(new URL(asset.url).pathname) + }; } export async function deleteStoredAsset(asset: Asset): Promise { @@ -218,6 +219,30 @@ export async function deleteStoredAsset(asset: Asset): Promise { } } +async function readLocalAsset(asset: Asset): Promise<{ + bytes: Buffer; + contentType: string; +} | null> { + const localPath = localServedPathParts(asset.storagePath) || localServedPathParts(asset.url); + if (!localPath) return null; + return readLocalServedFile(localPath.area, localPath.pathParts); +} + +function localServedPathParts(value?: string): { + area: "uploads" | "generated-results"; + pathParts: string[]; +} | null { + if (!value) return null; + const clean = value.replace(/^\/+/, ""); + if (clean.startsWith("uploads/")) { + return { area: "uploads", pathParts: clean.slice("uploads/".length).split("/").filter(Boolean) }; + } + if (clean.startsWith("generated-results/")) { + return { area: "generated-results", pathParts: clean.slice("generated-results/".length).split("/").filter(Boolean) }; + } + return null; +} + async function storeBuffer(input: { bytes: Buffer; fileName: string; diff --git a/lib/server/task-manager.ts b/lib/server/task-manager.ts new file mode 100644 index 0000000..50cd365 --- /dev/null +++ b/lib/server/task-manager.ts @@ -0,0 +1,151 @@ +import { randomUUID } from "node:crypto"; +import { + claimGenerationJobs, + clearGenerationJobLock, + getGenerationJob, + updateGenerationJob +} from "@/lib/server/data-store"; +import { advanceImageJob } from "@/lib/server/generation-service"; +import { advanceVideoJob } from "@/lib/server/video-generation-service"; +import { requestOrigin } from "@/lib/server/runtime"; +import { deliverJobWebhook } from "@/lib/server/webhook"; +import type { GenerationJob, GenerationStatus } from "@/lib/types"; + +export type WorkerTickResult = { + workerId: string; + claimed: number; + jobs: Array<{ + id: string; + status: GenerationStatus; + action: "processed" | "retry_scheduled" | "released" | "failed"; + error?: string; + }>; +}; + +const TERMINAL_STATUSES = new Set(["succeeded", "failed", "expired", "cancelled"]); + +export async function runWorkerTick(input: { + request?: Request; + origin?: string; + workerId?: string; + limit?: number; +} = {}): Promise { + const workerId = input.workerId || `worker-${randomUUID()}`; + const origin = input.origin || (input.request ? requestOrigin(input.request) : workerOrigin()); + const jobs = await claimGenerationJobs({ + workerId, + limit: input.limit || workerBatchSize(), + lockTimeoutMs: workerLockTimeoutMs() + }); + const result: WorkerTickResult = { + workerId, + claimed: jobs.length, + jobs: [] + }; + + for (const job of jobs) { + try { + const advanced = await advanceClaimedJob(job, origin); + const settled = await settleAdvancedJob(advanced); + result.jobs.push({ + id: settled.job.id, + status: settled.job.status, + action: settled.action + }); + } catch (error) { + const failed = await updateGenerationJob(job.id, { + status: "failed", + error: { + message: error instanceof Error ? error.message : String(error), + retryable: true + } + }); + const settled = await settleAdvancedJob(failed); + result.jobs.push({ + id: settled.job.id, + status: settled.job.status, + action: "failed", + error: error instanceof Error ? error.message : String(error) + }); + } + } + + return result; +} + +async function advanceClaimedJob(job: GenerationJob, origin: string): Promise { + if (job.capability === "video.generate") return advanceVideoJob(job.id, origin); + return advanceImageJob(job.id, origin); +} + +async function settleAdvancedJob(job: GenerationJob): Promise<{ + job: GenerationJob; + action: WorkerTickResult["jobs"][number]["action"]; +}> { + const current = await getGenerationJob(job.id) || job; + const now = new Date(); + + if (current.status === "failed" && canRetry(current)) { + const attempts = (current.attempts || 0) + 1; + const scheduledAt = new Date(now.getTime() + retryDelayMs(attempts)).toISOString(); + const retryJob = await clearGenerationJobLock(current.id, { + status: "queued", + attempts, + scheduledAt + }, { clearProviderTaskId: true }); + return { job: retryJob, action: "retry_scheduled" }; + } + + if (TERMINAL_STATUSES.has(current.status)) { + const terminalJob = await clearGenerationJobLock(current.id, { + attempts: current.status === "failed" ? (current.attempts || 0) + 1 : current.attempts, + completedAt: current.completedAt || now.toISOString() + }); + const webhook = await deliverJobWebhook(terminalJob); + if (webhook.lastStatus) { + const withWebhook = await updateGenerationJob(terminalJob.id, { + webhookAttempts: webhook.attempts, + webhookLastStatus: webhook.lastStatus + }); + return { job: withWebhook, action: "processed" }; + } + return { job: terminalJob, action: "processed" }; + } + + const scheduledAt = new Date(now.getTime() + workerPollIntervalMs()).toISOString(); + const released = await clearGenerationJobLock(current.id, { scheduledAt }); + return { job: released, action: "released" }; +} + +function canRetry(job: GenerationJob): boolean { + const attempts = job.attempts || 0; + const maxAttempts = job.maxAttempts || 3; + return Boolean(job.error?.retryable) && attempts < maxAttempts; +} + +function retryDelayMs(attempts: number): number { + const base = readPositiveInt("ZHINIAN_WORKER_RETRY_BASE_MS", 10_000); + const max = readPositiveInt("ZHINIAN_WORKER_RETRY_MAX_MS", 5 * 60 * 1000); + return Math.min(max, base * 2 ** Math.max(0, attempts - 1)); +} + +function workerPollIntervalMs(): number { + return readPositiveInt("ZHINIAN_WORKER_POLL_INTERVAL_MS", 5_000); +} + +function workerLockTimeoutMs(): number { + return readPositiveInt("ZHINIAN_WORKER_LOCK_TIMEOUT_MS", 5 * 60 * 1000); +} + +function workerBatchSize(): number { + return Math.max(1, Math.min(readPositiveInt("ZHINIAN_WORKER_BATCH_SIZE", 3), 20)); +} + +function workerOrigin(): string { + return (process.env.NEXT_PUBLIC_APP_URL || process.env.ZHINIAN_PUBLIC_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, ""); +} + +function readPositiveInt(name: string, fallback: number): number { + const parsed = Number(process.env[name]); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} diff --git a/lib/server/video-generation-service.ts b/lib/server/video-generation-service.ts index ef07002..349a52c 100644 --- a/lib/server/video-generation-service.ts +++ b/lib/server/video-generation-service.ts @@ -14,10 +14,16 @@ import { normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } export type SubmitVideoJobInput = PromptAssemblyInput & { ownerId?: string; + externalClientId?: string; prompt?: string; settings?: SeedanceSettings; materials?: PromptMaterial[]; retryOf?: string; + idempotencyKey?: string; + idempotencyFingerprint?: string; + priority?: number; + maxAttempts?: number; + webhookUrl?: string; }; export async function submitVideoJob(input: SubmitVideoJobInput, origin: string): Promise { @@ -36,6 +42,7 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string) const mock = shouldMockSeedance(); let job = await createGenerationJob({ ownerId, + externalClientId: input.externalClientId, capability: "video.generate", provider: mock ? "mock" : "seedance", reqKey: config.model, @@ -49,16 +56,38 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string) assembled, settings }, - retryOf: input.retryOf + retryOf: input.retryOf, + idempotencyKey: input.idempotencyKey, + idempotencyFingerprint: input.idempotencyFingerprint, + priority: input.priority, + maxAttempts: input.maxAttempts, + webhookUrl: input.webhookUrl }); - if (mock) return completeMockVideoJob(job); + return job; +} +export async function advanceVideoJob(jobId: string, origin: string): Promise { + const job = await getGenerationJob(jobId); + if (!job) throw new Error(`Generation job not found: ${jobId}`); + if (["succeeded", "failed", "cancelled", "expired"].includes(job.status)) return job; + if (job.provider === "mock") return completeMockVideoJob(job); + if (!job.providerTaskId) return dispatchVideoJob(job, origin); + return syncVideoJob(job.id, origin); +} + +async function dispatchVideoJob(job: GenerationJob, origin: string): Promise { try { + const input = asRecord(job.requestPayload.input) as SubmitVideoJobInput; + const assembled = asRecord(job.requestPayload.assembled); + const settings = asRecord(job.requestPayload.settings) as SeedanceSettings; + const materials = Array.isArray(assembled.materials) + ? assembled.materials as PromptMaterial[] + : input.materials || []; const response = await createSeedanceTask({ - prompt: finalPrompt, + prompt: job.prompt || "", settings, - materials: assembled.materials, + materials, origin }); return updateGenerationJob(job.id, { @@ -140,7 +169,7 @@ async function completeMockVideoJob(job: GenerationJob): Promise ownerId: job.ownerId, kind: "video", name: `mock-video-${job.id}.mp4`, - url: "/seedance-starter-assets/music_sync_ad/2-3-9-1/result.mp4", + url: "/mock/seedance-mock.mp4", source: "generated", tags: ["video.generate", "mock"], metadata: { @@ -168,3 +197,9 @@ async function completeMockVideoJob(job: GenerationJob): Promise } }); } + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} diff --git a/lib/server/webhook.ts b/lib/server/webhook.ts new file mode 100644 index 0000000..bb3f1bf --- /dev/null +++ b/lib/server/webhook.ts @@ -0,0 +1,71 @@ +import { createHmac } from "node:crypto"; +import type { GenerationJob, WebhookLastStatus } from "@/lib/types"; + +export type JobWebhookPayload = { + jobId: string; + status: GenerationJob["status"]; + capability: GenerationJob["capability"]; + outputAssetIds: string[]; + error?: GenerationJob["error"]; + updatedAt: string; +}; + +const MAX_WEBHOOK_ATTEMPTS = 3; + +export function buildJobWebhookPayload(job: GenerationJob): JobWebhookPayload { + return { + jobId: job.id, + status: job.status, + capability: job.capability, + outputAssetIds: job.outputAssetIds, + error: job.error, + updatedAt: job.updatedAt + }; +} + +export function signWebhookBody(body: string, secret = process.env.ZHINIAN_WEBHOOK_SECRET): string | undefined { + if (!secret?.trim()) return undefined; + return `sha256=${createHmac("sha256", secret.trim()).update(body).digest("hex")}`; +} + +export async function deliverJobWebhook(job: GenerationJob): Promise<{ + attempts: number; + lastStatus?: WebhookLastStatus; +}> { + if (!job.webhookUrl) return { attempts: job.webhookAttempts || 0 }; + let attempts = job.webhookAttempts || 0; + let lastStatus: WebhookLastStatus | undefined = job.webhookLastStatus; + const payload = buildJobWebhookPayload(job); + const body = JSON.stringify(payload); + const signature = signWebhookBody(body); + + while (attempts < MAX_WEBHOOK_ATTEMPTS) { + attempts += 1; + const attemptedAt = new Date().toISOString(); + try { + const response = await fetch(job.webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "zhinian-aigc-webhook/1.0", + ...(signature ? { "X-Zhinian-Signature": signature } : {}) + }, + body + }); + lastStatus = { + ok: response.ok, + status: response.status, + attemptedAt + }; + if (response.ok) break; + } catch (error) { + lastStatus = { + ok: false, + error: error instanceof Error ? error.message : String(error), + attemptedAt + }; + } + } + + return { attempts, lastStatus }; +} diff --git a/lib/types.ts b/lib/types.ts index 4410be5..3ac4574 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -19,6 +19,14 @@ export type GenerationStatus = | "expired" | "cancelled"; +export type WebhookLastStatus = { + ok: boolean; + status?: number; + error?: string; + attemptedAt: string; + nextAttemptAt?: string; +}; + export type Asset = { id: string; ownerId: string; @@ -36,6 +44,7 @@ export type Asset = { export type GenerationJob = { id: string; ownerId: string; + externalClientId?: string; capability: GenerationCapability; provider: "volcengine-visual" | "evolink" | "seedance" | "mock"; reqKey: string; @@ -53,6 +62,19 @@ export type GenerationJob = { retryable?: boolean; }; retryOf?: string; + idempotencyKey?: string; + idempotencyFingerprint?: string; + priority?: number; + attempts?: number; + maxAttempts?: number; + scheduledAt?: string; + lockedAt?: string; + lockedBy?: string; + startedAt?: string; + completedAt?: string; + webhookUrl?: string; + webhookAttempts?: number; + webhookLastStatus?: WebhookLastStatus; createdAt: string; updatedAt: string; }; diff --git a/package.json b/package.json index 4f7a088..efaac90 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,13 @@ "build": "next build", "prestart": "next build", "start": "next start --hostname 127.0.0.1 --port 3000", + "start:server": "next start --hostname 0.0.0.0 --port 3000", + "worker": "node scripts/worker.mjs", + "worker:once": "node scripts/worker.mjs --once", "health": "node scripts/health-check.mjs", "info": "node scripts/print-app-info.mjs", "test": "vitest run", - "test:watch": "vitest", - "legacy:start": "node scripts/start-runtime.mjs", - "legacy:info": "node scripts/print-runtime-info.mjs" + "test:watch": "vitest" }, "dependencies": { "@supabase/supabase-js": "^2.49.4", diff --git a/progress.md b/progress.md index 0233115..bd7b135 100644 --- a/progress.md +++ b/progress.md @@ -10,7 +10,7 @@ - Confirmed no prior planning files existed. - Created lightweight planning files for this project understanding pass. - Inspected top-level directory, Git status, file inventory, and root package/config candidates. - - Learned that this is not a Git repository and that the main app appears under `runtime/nianxx-play`. + - Learned that this is not a Git repository and that the main app appears under `removed extracted runtime`. - Read root README, root package metadata, runtime README, runtime package metadata, and a filtered non-media source list. - Files created/modified: - `task_plan.md` @@ -238,6 +238,46 @@ | 2026-05-29 | Browser REPL variable names collided across verification cells | 1 | Reused or renamed persistent variables instead of redeclaring constants | | 2026-05-29 | White logo required a dark frame on the light topbar and looked off-brand | 1 | Switched to black/blue logo, generated a transparent cropped asset, and removed frame styling | +## Session: 2026-05-29 - Server One-Command Deployment Support + +### Docker and Script Deployment +- **Status:** complete +- Actions taken: + - Added `.dockerignore` to keep secrets, local runtime data, Next build cache, dependencies, and bulky legacy media out of Docker build context. + - Added a multi-stage `Dockerfile` using Node 22 Alpine, `npm ci`, `next build`, production dependency pruning, and `next start` on `0.0.0.0`. + - Added `docker-compose.yml` with `zhinian-aigc` service, `.env.local` env file, `APP_PORT` host mapping, persistent `./.runtime:/app/.runtime`, restart policy, and HTTP healthcheck. + - Added `scripts/setup.sh` for local preparation. + - Added `scripts/deploy.sh` for server deployment with Docker Compose detection, `.env.local` creation, runtime directory creation, image build, and background startup. + - Added `start:server` npm script for non-Docker Node/PM2 deployment. + - Updated `.env.example` with `APP_PORT`. + - Expanded `README.zh-CN.md` with server deployment, Docker commands, Node deployment fallback, and backup notes. + - Added a short deployment entry to `README.md`. +- Files created/modified: + - `.dockerignore` + - `Dockerfile` + - `docker-compose.yml` + - `scripts/setup.sh` + - `scripts/deploy.sh` + - `.env.example` + - `package.json` + - `README.md` + - `README.zh-CN.md` + +## Test Results - Server Deployment Support +| Test | Input | Expected | Actual | Status | +|------|-------|----------|--------|--------| +| Shell syntax | `bash -n scripts/setup.sh scripts/deploy.sh` | No syntax errors | Passed | pass | +| Unit tests | `npm test` | All tests pass | 6 files / 16 tests passed | pass | +| Production build | `npm run build` | Build succeeds | Build succeeded | pass | +| Local health | `curl --noproxy '*' /api/health` | `ok: true` | `ok: true` | pass | +| Docker CLI availability | `docker --version` | Docker version if installed | No Docker CLI output in current environment | not run | + +## Error Log - Server Deployment Support +| Timestamp | Error | Attempt | Resolution | +|-----------|-------|---------|------------| +| 2026-05-29 | First `scripts/deploy.sh` draft had a shell quoting error while stripping quotes from `APP_PORT` | 1 | Simplified quote stripping and verified with `bash -n` | +| 2026-05-29 | Docker CLI is unavailable in the current local environment | 1 | Documented that Docker build should be validated on the target server; local Next build/test/health passed | + ## 5-Question Reboot Check | Question | Answer | |----------|--------| @@ -246,3 +286,34 @@ | What's the goal? | Understand and explain the whole project | | What have I learned? | This is an extracted standalone Next.js runtime for 智念创作助手, with local JSON persistence and optional Seedance/OSS integrations | | What have I done? | Completed repository survey, architecture mapping, and runtime verification | + +## Session: 2026-05-29 - Task Management and Public API v1 + +### Planning and Scope +- **Status:** in progress +- Actions taken: + - Confirmed the current app already has `GenerationJob`, image/video submit routes, polling routes, Supabase/local JSON persistence, and Docker Compose deployment support. + - Accepted the user decision that multi-task support should be implemented as task management logic, not a separate message queue system. + - Set the implementation path: API Key auth, `/api/v1` public routes, task-state/locking fields, provider execution via Worker, and a Docker Compose worker service. + +### Implementation +- **Status:** complete +- Actions taken: + - Extended `GenerationJob` with external client, idempotency, priority, retry, lock, timing, and webhook fields. + - Updated local JSON and Supabase mappings, plus `supabase/schema.sql` with queue indexes and `claim_generation_jobs`. + - Changed image/video submit services so creation enqueues jobs only; provider dispatch and polling now happen through `advanceImageJob` / `advanceVideoJob`. + - Added task manager, API Key auth, public idempotency helper, webhook signing/delivery, internal Worker tick route, and `scripts/worker.mjs`. + - Added `/api/v1/capabilities`, `/api/v1/assets`, `/api/v1/jobs`, `/api/v1/jobs/:id`, `/api/v1/jobs/:id/cancel`, and `/api/v1/openapi.json`. + - Added `npm run worker`, `npm run worker:once`, and a `zhinian-worker` Docker Compose service. + - Updated README files and `.env.example` with API/Worker/Webhook configuration. + +### Verification +- **Status:** complete +- Results: + - `npm test`: 7 files / 21 tests passed. + - `npm run build`: production build succeeded. + - `npm run health`: returned `ok: true`. + - Local `/api/v1/capabilities` with API Key returned capabilities. + - Local `/api/v1/jobs` created a queued job with idempotency key. + - `npm run worker:once` claimed the queued job and processed it to `succeeded` in mock mode. + - Docker CLI is unavailable in this local environment, so `docker compose up --build` still needs server-side validation. diff --git a/public/mock/seedance-mock.mp4 b/public/mock/seedance-mock.mp4 new file mode 100644 index 0000000..f364193 Binary files /dev/null and b/public/mock/seedance-mock.mp4 differ diff --git a/runtime/README.md b/runtime/README.md deleted file mode 100644 index e2f0b7d..0000000 --- a/runtime/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Runtime Layout - -`runtime/nianxx-play` is the extracted Next.js standalone runtime copied from the desktop app bundle for Zhinian Creation Assistant. - -This directory is intentionally treated as a generated/runtime artifact. The original NianxxPlay source project was deleted, so the editable source code is not fully recoverable from this bundle. - -Do not place user uploads, generated videos, or secrets under this directory. Runtime state is written to the root `.runtime/` directory by `scripts/start-runtime.mjs`. diff --git a/runtime/nianxx-play/bundle-manifest.json b/runtime/nianxx-play/bundle-manifest.json deleted file mode 100644 index 14512c0..0000000 --- a/runtime/nianxx-play/bundle-manifest.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "appId": "nianxx-play", - "name": "Zhinian Creation Assistant", - "version": "0.1.0", - "bundledAt": "2026-05-14T04:01:58.653Z", - "runtime": "next-standalone", - "entry": "server.js", - "excludes": [ - ".env*", - ".data", - "public/uploads", - "public/generated-results", - "development caches" - ], - "secretScan": { - "checked": true, - "sourceEnvValues": 3 - }, - "runtimeEnv": { - "bundled": false, - "extractedFromDesktopBundle": true, - "note": ".env.runtime was intentionally excluded during standalone extraction." - }, - "sizeBytes": 949760759 -} diff --git a/runtime/nianxx-play/content/planning-cases.json b/runtime/nianxx-play/content/planning-cases.json deleted file mode 100644 index 1e503ee..0000000 --- a/runtime/nianxx-play/content/planning-cases.json +++ /dev/null @@ -1,42 +0,0 @@ -[ - { - "id": "short-video-promo", - "title": "短视频宣传类", - "description": "适合门店种草、活动预热、社媒投放,用更短的节奏快速说清亮点。", - "videoUrl": "/planning-cases/short-video-promo.mp4", - "coverUrl": "/planning-cases/short-video-promo.jpg", - "orientation": "vertical" - }, - { - "id": "story-promo", - "title": "剧情宣传类", - "description": "通过故事场景强化品牌记忆与情绪转化,让宣传内容更有代入感。", - "videoUrl": "/planning-cases/story-promo.mp4", - "coverUrl": "/planning-cases/story-promo.jpg", - "orientation": "horizontal" - }, - { - "id": "trending-meme", - "title": "热门玩梗类", - "description": "适合热点借势、轻传播、年轻化表达,用熟悉的梗降低观看门槛。", - "videoUrl": "/planning-cases/trending-meme.mp4", - "coverUrl": "/planning-cases/trending-meme.jpg", - "orientation": "vertical" - }, - { - "id": "cartoon-ip", - "title": "卡通 IP 类", - "description": "用角色化表达承载品牌人格,适合打造长期记忆点和系列化内容。", - "videoUrl": "/planning-cases/cartoon-ip.mp4", - "coverUrl": "/planning-cases/cartoon-ip.jpg", - "orientation": "vertical" - }, - { - "id": "premium-brand", - "title": "品质高级类", - "description": "适合高客单、品牌升级、质感形象展示,让内容更接近品牌大片。", - "videoUrl": "/planning-cases/premium-brand.mp4", - "coverUrl": "/planning-cases/premium-brand.jpg", - "orientation": "horizontal" - } -] diff --git a/runtime/nianxx-play/content/seedance-starter/README.md b/runtime/nianxx-play/content/seedance-starter/README.md deleted file mode 100644 index d40e6c1..0000000 --- a/runtime/nianxx-play/content/seedance-starter/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Seedance Starter Content - -This folder contains startup reference content extracted from the Seedance 2.0 guide. It is meant to support a reference-first creation flow: users choose a proven example/template first, then replace materials or upload custom references later. - -## Files - -- `catalog.json`: case-level catalog with prompts, source attribution, local asset paths, public asset URLs, display metadata, and interaction hints. -- `creation-modes.json`: unified product-facing studio definition and startup example IDs. -- `../../public/seedance-starter-assets/`: downloaded images, thumbnails, reference videos, and result videos. - -## Imported Content - -- `music_sync_ad`: 4 guide cases from `09-music-sync.md`; product UI now shows them in the unified studio template strip. -- `creative_remix`: 8 guide cases from `03-creative-effects.md`. -- Total: 14 cases and 85 local asset records, including local promo starter examples. - -## UI Usage Pattern - -For the studio entry, read `creation-modes.json`. The app now has one canonical mode, `video_studio`. - -For the reference gallery, use the app helper `getReferenceTemplates()`. Any legacy mode argument is accepted for compatibility but no longer filters the template list: - -```js -const examples = getReferenceTemplates(); -``` - -For a selectable reference card: - -- Use `case.display.coverPublicUrl` as the thumbnail. -- Use `case.display.referenceVideoPublicUrl` for previewing the reference video. -- Use `case.display.resultVideoPublicUrl` for showing the generated example. -- Use `case.display.selectableAsReferenceTemplate` to decide whether the card can be used as a one-click reference template. - -## Product Interpretation - -### Unified Video Studio - -All music-sync, creative-remix,旁白, and达人 examples are presented as reference templates in one studio. The template provides style, rhythm, camera, transition, and effect reference; the user always edits project information, optional avatar/outfit, and storyboard scenes in the same storyboard editor. - -Core editor: `storyboard_cards`. - -## Attribution - -Source repository: https://github.com/EvoLinkAI/awesome-seedance-2-guide - -Imported pages: - -- https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md -- https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md - -Keep attribution metadata in downstream tools unless you replace these startup examples with owned/licensed content. diff --git a/runtime/nianxx-play/content/seedance-starter/catalog.json b/runtime/nianxx-play/content/seedance-starter/catalog.json deleted file mode 100644 index 03580fc..0000000 --- a/runtime/nianxx-play/content/seedance-starter/catalog.json +++ /dev/null @@ -1,1398 +0,0 @@ -{ - "generatedAt": "2026-05-03T13:52:55.451Z", - "purpose": "Startup reference content for Seedance-based creation modes. The UI should present these as selectable examples before custom upload.", - "sourceAttribution": { - "repository": "EvoLinkAI/awesome-seedance-2-guide", - "repositoryUrl": "https://github.com/EvoLinkAI/awesome-seedance-2-guide", - "importedPages": [ - "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md", - "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md" - ] - }, - "downloadFailures": [], - "cases": [ - { - "id": "promo-storefront-sample-1", - "slug": "promo-storefront-sample-1", - "mode": "storefront_avatar_storyboard", - "modeLabel": "宣传片制作", - "guideId": "local-promo-sample", - "title": "宣传片制作", - "inputSummary": "本地样片参考 + 店铺分镜素材", - "prompt": "参考@视频1的真实质感、空间氛围和转场节奏,结合用户上传素材,生成干净自然的通用宣传片。", - "promptPattern": { - "primaryReference": "local_storefront_reference_video", - "userControlledInputs": [ - "project_name", - "uploaded_materials", - "final_prompt" - ], - "seedanceInstruction": "将参考视频的真实质感、转场节奏和环境氛围迁移到用户上传素材上。" - }, - "interactionHooks": { - "editorType": "storyboard_cards", - "defaultUserAction": "选择参考样片后,逐段上传店铺分镜素材并修改口播。", - "visibleControls": [ - "分镜素材", - "口播", - "画面辅助", - "镜头辅助" - ], - "customUploadSecondary": false - }, - "display": { - "coverPublicUrl": "/starter/promo/wujiang-reference.jpg", - "referenceVideoPublicUrl": "/starter/promo/wujiang-reference.mp4", - "resultVideoPublicUrl": null, - "hasReferenceVideo": true, - "hasResultVideo": false, - "selectableAsReferenceTemplate": true - }, - "assets": [ - { - "role": "reference_video", - "sourceUrl": "/starter/promo/wujiang-reference.mp4", - "localPath": "public/starter/promo/wujiang-reference.mp4", - "publicUrl": "/starter/promo/wujiang-reference.mp4", - "fileName": "wujiang-reference.mp4" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "/starter/promo/wujiang-reference.jpg", - "localPath": "public/starter/promo/wujiang-reference.jpg", - "publicUrl": "/starter/promo/wujiang-reference.jpg", - "fileName": "wujiang-reference.jpg" - } - ], - "source": { - "title": "又见乌江名宿宣传片样片", - "page": "local-desktop", - "raw": "/starter/promo/wujiang-reference.mp4" - } - }, - { - "id": "promo-digital-human-host-1", - "slug": "promo-digital-human-host-1", - "mode": "storefront_avatar_storyboard", - "modeLabel": "宣传片制作", - "guideId": "local-digital-human-host", - "title": "达人模式", - "inputSummary": "固定达人形象 + 店铺分镜素材", - "prompt": "固定@图片1作为达人出镜形象,结合用户上传的店铺分镜素材,生成达人自然出镜讲解的本地商铺介绍视频。", - "promptPattern": { - "primaryReference": "fixed_digital_human_host", - "userControlledInputs": [ - "storefront_images", - "host_lines", - "scene_order" - ], - "seedanceInstruction": "保持@图片1达人形象稳定,让达人出镜讲解与店铺画面自然穿插,口播、口型和声音同步。" - }, - "interactionHooks": { - "editorType": "storyboard_cards", - "defaultUserAction": "选择达人模式后,逐段上传店铺分镜素材并修改口播。", - "visibleControls": [ - "固定达人", - "分镜素材", - "口播", - "画面辅助", - "镜头辅助" - ], - "customUploadSecondary": false - }, - "display": { - "coverPublicUrl": "/starter/promo/digital-human.jpg", - "referenceVideoPublicUrl": null, - "resultVideoPublicUrl": null, - "hasReferenceVideo": false, - "hasResultVideo": false, - "selectableAsReferenceTemplate": true - }, - "assets": [ - { - "role": "reference_image", - "sourceUrl": "/starter/promo/digital-human.jpg", - "localPath": "public/starter/promo/digital-human.jpg", - "publicUrl": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/promo/digital-human.jpg", - "fileName": "digital-human.jpg", - "promptLabel": "@图片1" - } - ], - "source": { - "title": "本地达人形象", - "page": "bundled-starter", - "raw": "/starter/promo/digital-human.jpg" - } - }, - { - "id": "2-3-9-1", - "slug": "2-3-9-1", - "mode": "music_sync_ad", - "modeLabel": "音乐卡点广告片", - "guideId": "09-music-sync", - "title": "时尚换装卡点", - "inputSummary": "4张图 + 1个参考视频(节奏)", - "prompt": "海报中的女生在不停的换装,服装参考@图片1@图片2的样式,手中提着@图片3的包,\n视频节奏参考@视频", - "promptPattern": { - "primaryReference": "reference_video_as_rhythm", - "userControlledInputs": [ - "ordered_images", - "style_intensity", - "scene_crop_freedom" - ], - "seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。", - "reusablePromptFragments": [ - "视频节奏参考@视频1", - "根据@视频1中的画面关键帧的位置和整体节奏进行卡点", - "可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化" - ] - }, - "interactionHooks": { - "editorType": "rhythm_timeline", - "defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。", - "visibleControls": [ - "参考节奏", - "素材出现顺序", - "卡点强度", - "景别自由度", - "整体风格" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.jpg", - "referenceVideoPublicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.mp4", - "resultVideoPublicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-1/result.mp4", - "hasReferenceVideo": true, - "hasResultVideo": true, - "selectableAsReferenceTemplate": true - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/1", - "localDir": "public/seedance-starter-assets/music_sync_ad/2-3-9-1", - "publicDir": "/seedance-starter-assets/music_sync_ad/2-3-9-1", - "assets": [ - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/1/ref1.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.png", - "fileName": "ref1.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/1/ref2.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref2.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-1/ref2.png", - "fileName": "ref2.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/1/ref3.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref3.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-1/ref3.png", - "fileName": "ref3.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/1/ref4.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref4.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-1/ref4.png", - "fileName": "ref4.png" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/1/ref1.jpg", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.jpg", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.jpg", - "fileName": "ref1.jpg" - }, - { - "role": "reference_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/1/ref1.mp4", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.mp4", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.mp4", - "fileName": "ref1.mp4" - }, - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/1/result.jpg", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-1/result.jpg", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-1/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/1/result.mp4", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-1/result.mp4", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-1/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "音乐卡点", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/09-music-sync.md" - } - }, - { - "id": "2-3-9-2", - "slug": "2-3-9-2", - "mode": "music_sync_ad", - "modeLabel": "音乐卡点广告片", - "guideId": "09-music-sync", - "title": "多风格图片卡点混剪", - "inputSummary": "6张风格图 + 1个参考视频(节奏)", - "prompt": "@图片1@图片2@图片3@图片4@图片5@图片6@图片7中的图片根据@视频中的画面关键帧的位置\n和整体节奏进行卡点,画面中的人物更有动感,整体画面风格更梦幻,画面张力强,可根据\n音乐及画面需求自行改变参考图的景别,及补充画面的光影变化", - "promptPattern": { - "primaryReference": "reference_video_as_rhythm", - "userControlledInputs": [ - "ordered_images", - "style_intensity", - "scene_crop_freedom" - ], - "seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。", - "reusablePromptFragments": [ - "视频节奏参考@视频1", - "根据@视频1中的画面关键帧的位置和整体节奏进行卡点", - "可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化" - ] - }, - "interactionHooks": { - "editorType": "rhythm_timeline", - "defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。", - "visibleControls": [ - "参考节奏", - "素材出现顺序", - "卡点强度", - "景别自由度", - "整体风格" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.jpg", - "referenceVideoPublicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.mp4", - "resultVideoPublicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/result.mp4", - "hasReferenceVideo": true, - "hasResultVideo": true, - "selectableAsReferenceTemplate": true - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2", - "localDir": "public/seedance-starter-assets/music_sync_ad/2-3-9-2", - "publicDir": "/seedance-starter-assets/music_sync_ad/2-3-9-2", - "assets": [ - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2/ref1.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.png", - "fileName": "ref1.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2/ref2.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref2.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/ref2.png", - "fileName": "ref2.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2/ref3.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref3.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/ref3.png", - "fileName": "ref3.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2/ref4.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref4.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/ref4.png", - "fileName": "ref4.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2/ref5.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref5.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/ref5.png", - "fileName": "ref5.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2/ref6.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref6.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/ref6.png", - "fileName": "ref6.png" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2/ref1.jpg", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.jpg", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.jpg", - "fileName": "ref1.jpg" - }, - { - "role": "reference_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2/ref1.mp4", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.mp4", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.mp4", - "fileName": "ref1.mp4" - }, - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2/result.jpg", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-2/result.jpg", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/2/result.mp4", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-2/result.mp4", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-2/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "音乐卡点", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/09-music-sync.md" - } - }, - { - "id": "2-3-9-3", - "slug": "2-3-9-3", - "mode": "music_sync_ad", - "modeLabel": "音乐卡点广告片", - "guideId": "09-music-sync", - "title": "风光大片卡点转场", - "inputSummary": "6张风景图 + 1个参考视频(节奏)", - "prompt": "@图片1@图片2@图片3@图片4@图片5@图片6的风光场景图,参考@视频中的画面节奏,\n转场间画面风格及音乐节奏进行卡点", - "promptPattern": { - "primaryReference": "reference_video_as_rhythm", - "userControlledInputs": [ - "ordered_images", - "style_intensity", - "scene_crop_freedom" - ], - "seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。", - "reusablePromptFragments": [ - "视频节奏参考@视频1", - "根据@视频1中的画面关键帧的位置和整体节奏进行卡点", - "可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化" - ] - }, - "interactionHooks": { - "editorType": "rhythm_timeline", - "defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。", - "visibleControls": [ - "参考节奏", - "素材出现顺序", - "卡点强度", - "景别自由度", - "整体风格" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.jpg", - "referenceVideoPublicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.mp4", - "resultVideoPublicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/result.mp4", - "hasReferenceVideo": true, - "hasResultVideo": true, - "selectableAsReferenceTemplate": true - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3", - "localDir": "public/seedance-starter-assets/music_sync_ad/2-3-9-3", - "publicDir": "/seedance-starter-assets/music_sync_ad/2-3-9-3", - "assets": [ - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3/ref1.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.png", - "fileName": "ref1.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3/ref2.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref2.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/ref2.png", - "fileName": "ref2.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3/ref3.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref3.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/ref3.png", - "fileName": "ref3.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3/ref4.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref4.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/ref4.png", - "fileName": "ref4.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3/ref5.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref5.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/ref5.png", - "fileName": "ref5.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3/ref6.png", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref6.png", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/ref6.png", - "fileName": "ref6.png" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3/ref1.jpg", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.jpg", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.jpg", - "fileName": "ref1.jpg" - }, - { - "role": "reference_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3/ref1.mp4", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.mp4", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.mp4", - "fileName": "ref1.mp4" - }, - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3/result.jpg", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-3/result.jpg", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/3/result.mp4", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-3/result.mp4", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-3/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "音乐卡点", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/09-music-sync.md" - } - }, - { - "id": "2-3-9-4", - "slug": "2-3-9-4", - "mode": "music_sync_ad", - "modeLabel": "音乐卡点广告片", - "guideId": "09-music-sync", - "title": "动漫分镜 + 战斗卡点", - "inputSummary": "纯文本(详细分镜脚本)", - "prompt": "8秒智性博弈式战斗动漫片段,贴合复仇主题。\n0-3秒:女主转身坐下,转镜头,女主下了一步棋子,并说\"你输了\"。\n3-4秒:快速摇镜头,转向对面男人面部特写,男人咬牙切齿,对结果很不满。\n4-6秒:切镜头,俯拍,女人下了一步棋,对面的人们惊叹。\n6-8秒:镜头迅速向下摇,画面黑屏转场,后画面渐亮,昏暗室内,女人看着窗外月色静静地说\n\"我们走着瞧\"。", - "promptPattern": { - "primaryReference": "reference_video_as_rhythm", - "userControlledInputs": [ - "ordered_images", - "style_intensity", - "scene_crop_freedom" - ], - "seedanceInstruction": "让图片/画面根据参考视频的画面关键帧和整体节奏进行卡点。", - "reusablePromptFragments": [ - "视频节奏参考@视频1", - "根据@视频1中的画面关键帧的位置和整体节奏进行卡点", - "可根据音乐及画面需求自行改变参考图的景别,及补充画面的光影变化" - ] - }, - "interactionHooks": { - "editorType": "rhythm_timeline", - "defaultUserAction": "选择一个参考节奏视频,然后按出现顺序放入图片素材。", - "visibleControls": [ - "参考节奏", - "素材出现顺序", - "卡点强度", - "景别自由度", - "整体风格" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-4/result.jpg", - "referenceVideoPublicUrl": null, - "resultVideoPublicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-4/result.mp4", - "hasReferenceVideo": false, - "hasResultVideo": true, - "selectableAsReferenceTemplate": false - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/4", - "localDir": "public/seedance-starter-assets/music_sync_ad/2-3-9-4", - "publicDir": "/seedance-starter-assets/music_sync_ad/2-3-9-4", - "assets": [ - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/4/result.jpg", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-4/result.jpg", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-4/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-9/4/result.mp4", - "localPath": "public/seedance-starter-assets/music_sync_ad/2-3-9-4/result.mp4", - "publicUrl": "/seedance-starter-assets/music_sync_ad/2-3-9-4/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "音乐卡点", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/09-music-sync.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/09-music-sync.md" - } - }, - { - "id": "2-3-3-1", - "slug": "2-3-3-1", - "mode": "creative_remix", - "modeLabel": "创意视频复刻", - "guideId": "03-creative-effects", - "title": "科幻眼镜穿越多世界", - "inputSummary": "4张场景图 + 1个参考视频", - "prompt": "将@视频1的人物换成@图片1,@图片1为首帧,人物带上虚拟科幻眼镜,参考@视频1的运镜,\n及近的环绕镜头,从第三人称视角变成人物的主观视角,在AI虚拟眼镜中穿梭,来到@图片2\n的深邃的蓝色宇宙,出现几架飞���穿梭向远方,镜头跟随飞船穿梭到@图片3的像素世界,\n镜头低空飞过像素的山林世界,里面的树木生长形式出现,随后视角仰拍,急速穿梭到\n@图片4的浅绿色纹理的星球,镜头穿梭并掠过星球表面", - "promptPattern": { - "primaryReference": "reference_video_as_effect_template", - "userControlledInputs": [ - "replacement_subject", - "replacement_scene", - "text_or_logo_replacement", - "preserve_motion", - "preserve_effect", - "preserve_rhythm" - ], - "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", - "reusablePromptFragments": [ - "完全参考@视频1的特效和动作", - "参考@视频1的运镜", - "将@视频1的首帧人物替换成@图片1", - "文字替换成用户提供的品牌文案或Logo" - ] - }, - "interactionHooks": { - "editorType": "reference_mapping", - "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", - "visibleControls": [ - "保留运镜", - "保留动作", - "保留特效", - "保留节奏", - "替换主体", - "替换场景", - "替换文字/Logo" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-1/ref1.jpg", - "referenceVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-1/ref1.mp4", - "resultVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-1/result.mp4", - "hasReferenceVideo": true, - "hasResultVideo": true, - "selectableAsReferenceTemplate": true - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/1", - "localDir": "public/seedance-starter-assets/creative_remix/2-3-3-1", - "publicDir": "/seedance-starter-assets/creative_remix/2-3-3-1", - "assets": [ - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/1/ref1.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-1/ref1.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-1/ref1.png", - "fileName": "ref1.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/1/ref2.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-1/ref2.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-1/ref2.png", - "fileName": "ref2.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/1/ref3.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-1/ref3.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-1/ref3.png", - "fileName": "ref3.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/1/ref4.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-1/ref4.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-1/ref4.png", - "fileName": "ref4.png" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/1/ref1.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-1/ref1.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-1/ref1.jpg", - "fileName": "ref1.jpg" - }, - { - "role": "reference_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/1/ref1.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-1/ref1.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-1/ref1.mp4", - "fileName": "ref1.mp4" - }, - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/1/result.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-1/result.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-1/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/1/result.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-1/result.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-1/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "创意模板 / 复杂特效精准复刻", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/03-creative-effects.md" - } - }, - { - "id": "2-3-3-2", - "slug": "2-3-3-2", - "mode": "creative_remix", - "modeLabel": "创意视频复刻", - "guideId": "03-creative-effects", - "title": "鱼眼换装闪切", - "inputSummary": "6张图(人物+服装)+ 1个参考视频", - "prompt": "参考第一张图片里模特的五官长相。模特分别穿着第2-6张参考图里的服装凑近镜头,\n做出调皮、冷酷、可爱、惊讶、耍帅的造型,每一个造型穿着不同服装,每次更换,\n画面伴随会切镜,参考视频的里鱼眼镜头效果、重影闪烁的炫影画面效果,参考@视频1", - "promptPattern": { - "primaryReference": "reference_video_as_effect_template", - "userControlledInputs": [ - "replacement_subject", - "replacement_scene", - "text_or_logo_replacement", - "preserve_motion", - "preserve_effect", - "preserve_rhythm" - ], - "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", - "reusablePromptFragments": [ - "完全参考@视频1的特效和动作", - "参考@视频1的运镜", - "将@视频1的首帧人物替换成@图片1", - "文字替换成用户提供的品牌文案或Logo" - ] - }, - "interactionHooks": { - "editorType": "reference_mapping", - "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", - "visibleControls": [ - "保留运镜", - "保留动作", - "保留特效", - "保留节奏", - "替换主体", - "替换场景", - "替换文字/Logo" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/ref1.jpg", - "referenceVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/ref1.mp4", - "resultVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/result.mp4", - "hasReferenceVideo": true, - "hasResultVideo": true, - "selectableAsReferenceTemplate": true - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2", - "localDir": "public/seedance-starter-assets/creative_remix/2-3-3-2", - "publicDir": "/seedance-starter-assets/creative_remix/2-3-3-2", - "assets": [ - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2/ref1.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-2/ref1.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/ref1.png", - "fileName": "ref1.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2/ref2.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-2/ref2.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/ref2.png", - "fileName": "ref2.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2/ref3.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-2/ref3.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/ref3.png", - "fileName": "ref3.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2/ref4.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-2/ref4.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/ref4.png", - "fileName": "ref4.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2/ref5.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-2/ref5.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/ref5.png", - "fileName": "ref5.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2/ref6.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-2/ref6.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/ref6.png", - "fileName": "ref6.png" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2/ref1.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-2/ref1.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/ref1.jpg", - "fileName": "ref1.jpg" - }, - { - "role": "reference_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2/ref1.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-2/ref1.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/ref1.mp4", - "fileName": "ref1.mp4" - }, - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2/result.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-2/result.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/2/result.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-2/result.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-2/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "创意模板 / 复杂特效精准复刻", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/03-creative-effects.md" - } - }, - { - "id": "2-3-3-3", - "slug": "2-3-3-3", - "mode": "creative_remix", - "modeLabel": "创意视频复刻", - "guideId": "03-creative-effects", - "title": "羽绒服广告创意复刻", - "inputSummary": "3张图 + 1个参考视频", - "prompt": "参考视频的广告创意,用提供的羽绒服图片,并参考鹅绒图片、天鹅图片,搭配以下广告词\n\"这是根鹅绒,这是暖天鹅,这是能穿的极地天鹅绒羽绒服,新年穿得暖,生活过得暖\",\n生成新的羽绒服广告视频。", - "promptPattern": { - "primaryReference": "reference_video_as_effect_template", - "userControlledInputs": [ - "replacement_subject", - "replacement_scene", - "text_or_logo_replacement", - "preserve_motion", - "preserve_effect", - "preserve_rhythm" - ], - "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", - "reusablePromptFragments": [ - "完全参考@视频1的特效和动作", - "参考@视频1的运镜", - "将@视频1的首帧人物替换成@图片1", - "文字替换成用户提供的品牌文案或Logo" - ] - }, - "interactionHooks": { - "editorType": "reference_mapping", - "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", - "visibleControls": [ - "保留运镜", - "保留动作", - "保留特效", - "保留节奏", - "替换主体", - "替换场景", - "替换文字/Logo" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-3/ref1.jpg", - "referenceVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-3/ref1.mp4", - "resultVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-3/result.mp4", - "hasReferenceVideo": true, - "hasResultVideo": true, - "selectableAsReferenceTemplate": true - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/3", - "localDir": "public/seedance-starter-assets/creative_remix/2-3-3-3", - "publicDir": "/seedance-starter-assets/creative_remix/2-3-3-3", - "assets": [ - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/3/ref1.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-3/ref1.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-3/ref1.png", - "fileName": "ref1.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/3/ref2.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-3/ref2.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-3/ref2.png", - "fileName": "ref2.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/3/ref3.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-3/ref3.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-3/ref3.png", - "fileName": "ref3.png" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/3/ref1.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-3/ref1.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-3/ref1.jpg", - "fileName": "ref1.jpg" - }, - { - "role": "reference_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/3/ref1.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-3/ref1.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-3/ref1.mp4", - "fileName": "ref1.mp4" - }, - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/3/result.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-3/result.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-3/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/3/result.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-3/result.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-3/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "创意模板 / 复杂特效精准复刻", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/03-creative-effects.md" - } - }, - { - "id": "2-3-3-4", - "slug": "2-3-3-4", - "mode": "creative_remix", - "modeLabel": "创意视频复刻", - "guideId": "03-creative-effects", - "title": "水墨太极功夫", - "inputSummary": "1张人物图 + 1个参考视频", - "prompt": "黑白水墨风格,@图片1的人物参考@视频1的特效和动作,上演一段水墨太极功夫", - "promptPattern": { - "primaryReference": "reference_video_as_effect_template", - "userControlledInputs": [ - "replacement_subject", - "replacement_scene", - "text_or_logo_replacement", - "preserve_motion", - "preserve_effect", - "preserve_rhythm" - ], - "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", - "reusablePromptFragments": [ - "完全参考@视频1的特效和动作", - "参考@视频1的运镜", - "将@视频1的首帧人物替换成@图片1", - "文字替换成用户提供的品牌文案或Logo" - ] - }, - "interactionHooks": { - "editorType": "reference_mapping", - "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", - "visibleControls": [ - "保留运镜", - "保留动作", - "保留特效", - "保留节奏", - "替换主体", - "替换场景", - "替换文字/Logo" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-4/ref1.jpg", - "referenceVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-4/ref1.mp4", - "resultVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-4/result.mp4", - "hasReferenceVideo": true, - "hasResultVideo": true, - "selectableAsReferenceTemplate": true - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/4", - "localDir": "public/seedance-starter-assets/creative_remix/2-3-3-4", - "publicDir": "/seedance-starter-assets/creative_remix/2-3-3-4", - "assets": [ - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/4/ref1.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-4/ref1.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-4/ref1.png", - "fileName": "ref1.png" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/4/ref1.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-4/ref1.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-4/ref1.jpg", - "fileName": "ref1.jpg" - }, - { - "role": "reference_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/4/ref1.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-4/ref1.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-4/ref1.mp4", - "fileName": "ref1.mp4" - }, - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/4/result.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-4/result.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-4/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/4/result.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-4/result.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-4/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "创意模板 / 复杂特效精准复刻", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/03-creative-effects.md" - } - }, - { - "id": "2-3-3-5", - "slug": "2-3-3-5", - "mode": "creative_remix", - "modeLabel": "创意视频复刻", - "guideId": "03-creative-effects", - "title": "角色变装特效(玫瑰蔓延)", - "inputSummary": "2张人物图 + 1个参考视频", - "prompt": "将@视频1的首帧人物替换成@图片1,完全@参考视频1的特效和动作,手里的花蕊长出玫瑰\n花瓣,裂纹在脸部向上延伸,逐渐被杂草覆盖,人物双手拂过脸部,杂草变成粒子消散,\n最后变成@图片2的长相", - "promptPattern": { - "primaryReference": "reference_video_as_effect_template", - "userControlledInputs": [ - "replacement_subject", - "replacement_scene", - "text_or_logo_replacement", - "preserve_motion", - "preserve_effect", - "preserve_rhythm" - ], - "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", - "reusablePromptFragments": [ - "完全参考@视频1的特效和动作", - "参考@视频1的运镜", - "将@视频1的首帧人物替换成@图片1", - "文字替换成用户提供的品牌文案或Logo" - ] - }, - "interactionHooks": { - "editorType": "reference_mapping", - "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", - "visibleControls": [ - "保留运镜", - "保留动作", - "保留特效", - "保留节奏", - "替换主体", - "替换场景", - "替换文字/Logo" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-5/ref1.jpg", - "referenceVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-5/ref1.mp4", - "resultVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-5/result.mp4", - "hasReferenceVideo": true, - "hasResultVideo": true, - "selectableAsReferenceTemplate": true - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/5", - "localDir": "public/seedance-starter-assets/creative_remix/2-3-3-5", - "publicDir": "/seedance-starter-assets/creative_remix/2-3-3-5", - "assets": [ - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/5/ref1.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-5/ref1.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-5/ref1.png", - "fileName": "ref1.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/5/ref2.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-5/ref2.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-5/ref2.png", - "fileName": "ref2.png" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/5/ref1.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-5/ref1.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-5/ref1.jpg", - "fileName": "ref1.jpg" - }, - { - "role": "reference_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/5/ref1.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-5/ref1.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-5/ref1.mp4", - "fileName": "ref1.mp4" - }, - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/5/result.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-5/result.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-5/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/5/result.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-5/result.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-5/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "创意模板 / 复杂特效精准复刻", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/03-creative-effects.md" - } - }, - { - "id": "2-3-3-6", - "slug": "2-3-3-6", - "mode": "creative_remix", - "modeLabel": "创意视频复刻", - "guideId": "03-creative-effects", - "title": "拼图破碎转场 + 文字替换", - "inputSummary": "2张图 + 1个参考视频", - "prompt": "由@图片1的天花板开始,参考@视频1的拼图破碎效果进行转场,\"BELIEVE\"字体替换成\n\"Seedance\",参考@图2的字体", - "promptPattern": { - "primaryReference": "reference_video_as_effect_template", - "userControlledInputs": [ - "replacement_subject", - "replacement_scene", - "text_or_logo_replacement", - "preserve_motion", - "preserve_effect", - "preserve_rhythm" - ], - "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", - "reusablePromptFragments": [ - "完全参考@视频1的特效和动作", - "参考@视频1的运镜", - "将@视频1的首帧人物替换成@图片1", - "文字替换成用户提供的品牌文案或Logo" - ] - }, - "interactionHooks": { - "editorType": "reference_mapping", - "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", - "visibleControls": [ - "保留运镜", - "保留动作", - "保留特效", - "保留节奏", - "替换主体", - "替换场景", - "替换文字/Logo" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-6/ref1.jpg", - "referenceVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-6/ref1.mp4", - "resultVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-6/result.mp4", - "hasReferenceVideo": true, - "hasResultVideo": true, - "selectableAsReferenceTemplate": true - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/6", - "localDir": "public/seedance-starter-assets/creative_remix/2-3-3-6", - "publicDir": "/seedance-starter-assets/creative_remix/2-3-3-6", - "assets": [ - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/6/ref1.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-6/ref1.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-6/ref1.png", - "fileName": "ref1.png" - }, - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/6/ref2.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-6/ref2.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-6/ref2.png", - "fileName": "ref2.png" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/6/ref1.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-6/ref1.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-6/ref1.jpg", - "fileName": "ref1.jpg" - }, - { - "role": "reference_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/6/ref1.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-6/ref1.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-6/ref1.mp4", - "fileName": "ref1.mp4" - }, - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/6/result.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-6/result.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-6/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/6/result.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-6/result.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-6/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "创意模板 / 复杂特效精准复刻", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/03-creative-effects.md" - } - }, - { - "id": "2-3-3-7", - "slug": "2-3-3-7", - "mode": "creative_remix", - "modeLabel": "创意视频复刻", - "guideId": "03-creative-effects", - "title": "金色粒子片头", - "inputSummary": "1张文字/Logo图 + 1个参考视频", - "prompt": "以黑幕开场,参考视频1的粒子特效和材质,金色鎏金材质的沙砾从画面左边飘出并向右覆盖,\n参考@视频1的粒子吹散效果,@图片1的字体逐渐出现在画面中心", - "promptPattern": { - "primaryReference": "reference_video_as_effect_template", - "userControlledInputs": [ - "replacement_subject", - "replacement_scene", - "text_or_logo_replacement", - "preserve_motion", - "preserve_effect", - "preserve_rhythm" - ], - "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", - "reusablePromptFragments": [ - "完全参考@视频1的特效和动作", - "参考@视频1的运镜", - "将@视频1的首帧人物替换成@图片1", - "文字替换成用户提供的品牌文案或Logo" - ] - }, - "interactionHooks": { - "editorType": "reference_mapping", - "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", - "visibleControls": [ - "保留运镜", - "保留动作", - "保留特效", - "保留节奏", - "替换主体", - "替换场景", - "替换文字/Logo" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-7/ref1.jpg", - "referenceVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-7/ref1.mp4", - "resultVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-7/result.mp4", - "hasReferenceVideo": true, - "hasResultVideo": true, - "selectableAsReferenceTemplate": true - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/7", - "localDir": "public/seedance-starter-assets/creative_remix/2-3-3-7", - "publicDir": "/seedance-starter-assets/creative_remix/2-3-3-7", - "assets": [ - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/7/ref1.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-7/ref1.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-7/ref1.png", - "fileName": "ref1.png" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/7/ref1.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-7/ref1.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-7/ref1.jpg", - "fileName": "ref1.jpg" - }, - { - "role": "reference_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/7/ref1.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-7/ref1.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-7/ref1.mp4", - "fileName": "ref1.mp4" - }, - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/7/result.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-7/result.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-7/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/7/result.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-7/result.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-7/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "创意模板 / 复杂特效精准复刻", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/03-creative-effects.md" - } - }, - { - "id": "2-3-3-8", - "slug": "2-3-3-8", - "mode": "creative_remix", - "modeLabel": "创意视频复刻", - "guideId": "03-creative-effects", - "title": "吃泡面抽象行为艺术", - "inputSummary": "1张人物图 + 1个参考视频", - "prompt": "@图片1的人物参考@视频1中的动作和表情变化,展示吃泡面的抽象行为", - "promptPattern": { - "primaryReference": "reference_video_as_effect_template", - "userControlledInputs": [ - "replacement_subject", - "replacement_scene", - "text_or_logo_replacement", - "preserve_motion", - "preserve_effect", - "preserve_rhythm" - ], - "seedanceInstruction": "明确说明完全参考@视频1的特效/动作/运镜,同时把原视频主体替换为用户素材。", - "reusablePromptFragments": [ - "完全参考@视频1的特效和动作", - "参考@视频1的运镜", - "将@视频1的首帧人物替换成@图片1", - "文字替换成用户提供的品牌文案或Logo" - ] - }, - "interactionHooks": { - "editorType": "reference_mapping", - "defaultUserAction": "选择一个创意参考视频,然后映射要替换的主体、场景、文字和Logo。", - "visibleControls": [ - "保留运镜", - "保留动作", - "保留特效", - "保留节奏", - "替换主体", - "替换场景", - "替换文字/Logo" - ], - "customUploadSecondary": true - }, - "display": { - "coverPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-8/ref1.jpg", - "referenceVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-8/ref1.mp4", - "resultVideoPublicUrl": "/seedance-starter-assets/creative_remix/2-3-3-8/result.mp4", - "hasReferenceVideo": true, - "hasResultVideo": true, - "selectableAsReferenceTemplate": true - }, - "assetBase": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/8", - "localDir": "public/seedance-starter-assets/creative_remix/2-3-3-8", - "publicDir": "/seedance-starter-assets/creative_remix/2-3-3-8", - "assets": [ - { - "role": "reference_image", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/8/ref1.png", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-8/ref1.png", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-8/ref1.png", - "fileName": "ref1.png" - }, - { - "role": "reference_video_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/8/ref1.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-8/ref1.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-8/ref1.jpg", - "fileName": "ref1.jpg" - }, - { - "role": "reference_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/8/ref1.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-8/ref1.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-8/ref1.mp4", - "fileName": "ref1.mp4" - }, - { - "role": "result_thumbnail", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/8/result.jpg", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-8/result.jpg", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-8/result.jpg", - "fileName": "result.jpg" - }, - { - "role": "result_video", - "sourceUrl": "https://pub-babc88c25d274cfeb8b2ae0cd0816872.r2.dev/assets/2-3-3/8/result.mp4", - "localPath": "public/seedance-starter-assets/creative_remix/2-3-3-8/result.mp4", - "publicUrl": "/seedance-starter-assets/creative_remix/2-3-3-8/result.mp4", - "fileName": "result.mp4" - } - ], - "source": { - "title": "创意模板 / 复杂特效精准复刻", - "page": "https://github.com/EvoLinkAI/awesome-seedance-2-guide/blob/main/use-cases/zh-CN/03-creative-effects.md", - "raw": "https://raw.githubusercontent.com/EvoLinkAI/awesome-seedance-2-guide/main/use-cases/zh-CN/03-creative-effects.md" - } - } - ] -} diff --git a/runtime/nianxx-play/content/seedance-starter/creation-modes.json b/runtime/nianxx-play/content/seedance-starter/creation-modes.json deleted file mode 100644 index 3efdeb9..0000000 --- a/runtime/nianxx-play/content/seedance-starter/creation-modes.json +++ /dev/null @@ -1,23 +0,0 @@ -[ - { - "id": "video_studio", - "name": "宣传片创作台", - "status": "seeded_from_guide", - "editorType": "storyboard_cards", - "referenceFirst": true, - "customUploadSecondary": false, - "startupExamples": [ - "promo-storefront-sample-1" - ], - "note": "统一创作台使用同一套分镜、素材、提示词和生成设置;模板只决定参考风格、节奏、运镜和特效。", - "assetSlots": [ - "出境数字人", - "服装配套", - "分镜图片", - "参考视频", - "参考音乐", - "补充素材" - ], - "promptAssembly": "选择参考模板后,用 5 段分镜维护项目内容;数字人和服装可选,提示词实时融合模板风格、分镜、素材和生成参数。" - } -] diff --git a/runtime/nianxx-play/content/seedance-starter/oss-manifest.json b/runtime/nianxx-play/content/seedance-starter/oss-manifest.json deleted file mode 100644 index b63459f..0000000 --- a/runtime/nianxx-play/content/seedance-starter/oss-manifest.json +++ /dev/null @@ -1,179 +0,0 @@ -{ - "generatedAt": "2026-05-04T07:57:11.711Z", - "bucket": "one-feel-ota-data", - "endpoint": "https://oss-cn-guangzhou.aliyuncs.com", - "publicBaseUrl": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com", - "prefix": "nianxxplay", - "totalAssets": 82, - "totalBytes": 359827961, - "assets": { - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref3.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref3.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref2.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref2.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.mp4", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/result.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/result.mp4", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref2.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref2.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref3.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref3.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref4.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref4.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref4.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref4.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref6.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref6.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/result.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref5.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref5.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.mp4", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/result.mp4", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref3.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref3.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref4.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref4.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref2.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref2.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref6.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref6.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/result.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref5.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref5.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-4/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-4/result.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/result.mp4", - "public/seedance-starter-assets/music_sync_ad/2-3-9-4/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-4/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref2.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref2.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref1.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref3.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref3.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref1.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref4.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref4.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/result.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref1.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref2.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref2.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref1.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref3.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref3.png", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref4.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref4.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref1.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref5.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref5.png", - "public/seedance-starter-assets/creative_remix/2-3-3-2/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref6.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref6.png", - "public/seedance-starter-assets/creative_remix/2-3-3-3/ref1.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-3/ref2.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/ref2.png", - "public/seedance-starter-assets/creative_remix/2-3-3-3/ref3.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/ref3.png", - "public/seedance-starter-assets/creative_remix/2-3-3-3/ref1.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-2/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-3/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref1.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-4/ref1.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-4/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-4/ref1.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-4/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-3/ref1.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-4/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-4/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-4/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-4/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-5/ref1.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-5/ref2.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/ref2.png", - "public/seedance-starter-assets/creative_remix/2-3-3-5/ref1.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-4/ref1.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-4/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-5/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-5/ref1.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-5/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-6/ref1.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-6/ref1.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-6/ref2.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/ref2.png", - "public/seedance-starter-assets/creative_remix/2-3-3-6/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-6/ref1.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-7/ref1.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-7/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-7/ref1.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-7/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-3/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-7/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-7/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-7/ref1.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-7/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-8/ref1.png": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-8/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-8/ref1.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-8/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-7/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-7/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-8/result.jpg": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-8/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-8/ref1.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-8/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-8/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-8/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-6/result.mp4": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/result.mp4" - }, - "objects": { - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref3.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref3.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref2.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref2.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.jpg": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.mp4": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref1.mp4", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/result.jpg": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/result.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/result.mp4": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/result.mp4", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref2.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref2.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref3.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref3.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-1/ref4.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-1/ref4.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref4.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref4.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.jpg": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref6.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref6.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/result.jpg": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/result.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref5.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref5.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.mp4": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/ref1.mp4", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-2/result.mp4": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-2/result.mp4", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref3.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref3.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref4.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref4.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref2.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref2.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.jpg": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref6.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref6.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/result.jpg": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/result.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref5.png": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref5.png", - "public/seedance-starter-assets/music_sync_ad/2-3-9-4/result.jpg": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-4/result.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/result.mp4": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/result.mp4", - "public/seedance-starter-assets/music_sync_ad/2-3-9-4/result.mp4": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-4/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref2.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref2.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref1.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref3.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref3.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref1.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref4.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref4.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/result.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/result.jpg", - "public/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.mp4": "nianxxplay/seedance-starter-assets/music_sync_ad/2-3-9-3/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref1.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref2.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref2.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/ref1.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref3.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref3.png", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref4.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref4.png", - "public/seedance-starter-assets/creative_remix/2-3-3-1/result.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-1/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref1.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref5.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref5.png", - "public/seedance-starter-assets/creative_remix/2-3-3-2/result.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref6.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref6.png", - "public/seedance-starter-assets/creative_remix/2-3-3-3/ref1.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-3/ref2.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/ref2.png", - "public/seedance-starter-assets/creative_remix/2-3-3-3/ref3.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/ref3.png", - "public/seedance-starter-assets/creative_remix/2-3-3-3/ref1.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-2/result.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-3/result.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-2/ref1.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-2/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-4/ref1.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-4/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-4/ref1.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-4/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-3/ref1.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-4/result.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-4/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-4/result.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-4/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-5/ref1.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-5/ref2.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/ref2.png", - "public/seedance-starter-assets/creative_remix/2-3-3-5/ref1.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-4/ref1.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-4/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-5/result.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-5/ref1.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-5/result.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-5/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-6/ref1.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-6/ref1.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-6/ref2.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/ref2.png", - "public/seedance-starter-assets/creative_remix/2-3-3-6/result.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-6/ref1.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-7/ref1.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-7/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-7/ref1.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-7/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-3/result.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-3/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-7/result.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-7/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-7/ref1.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-7/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-8/ref1.png": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-8/ref1.png", - "public/seedance-starter-assets/creative_remix/2-3-3-8/ref1.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-8/ref1.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-7/result.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-7/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-8/result.jpg": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-8/result.jpg", - "public/seedance-starter-assets/creative_remix/2-3-3-8/ref1.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-8/ref1.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-8/result.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-8/result.mp4", - "public/seedance-starter-assets/creative_remix/2-3-3-6/result.mp4": "nianxxplay/seedance-starter-assets/creative_remix/2-3-3-6/result.mp4" - }, - "signedUrlRequired": true, - "updatedAt": "2026-05-04T07:59:25.147Z" -} diff --git a/runtime/nianxx-play/content/studio-presets/avatar-outfits.json b/runtime/nianxx-play/content/studio-presets/avatar-outfits.json deleted file mode 100644 index c204184..0000000 --- a/runtime/nianxx-play/content/studio-presets/avatar-outfits.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "models": [ - { - "id": "digital-human-host", - "title": "默认人物参考", - "coverUrl": "/starter/promo/digital-human.jpg", - "assetUrl": "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/nianxxplay/seedance-starter-assets/promo/digital-human.jpg", - "promptText": "人物形象参考@图片1,保持同一位人物的五官、发型和气质稳定,动作自然,不遮挡项目主体。" - } - ], - "outfits": [ - { - "id": "default", - "title": "不指定服装", - "coverUrl": "/starter/promo/digital-human.jpg", - "promptText": "服装保持干净自然,符合宣传片整体质感,不夸张抢戏。" - } - ], - "pairings": [ - { - "id": "digital-human-host-default", - "modelId": "digital-human-host", - "outfitId": "default", - "title": "默认人物搭配", - "coverUrl": "/starter/promo/digital-human.jpg", - "promptText": "使用默认人物参考和自然干净服装,人物只作为辅助画面元素。" - } - ] -} diff --git a/runtime/nianxx-play/package.json b/runtime/nianxx-play/package.json deleted file mode 100644 index 8c4488f..0000000 --- a/runtime/nianxx-play/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "nianxxplay", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "@supabase/supabase-js": "^2.49.4", - "ali-oss": "^6.23.0", - "clsx": "^2.1.1", - "graceful-fs": "^4.2.11", - "lucide-react": "^0.468.0", - "next": "^15.1.4", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.1.0", - "@types/node": "^22.10.5", - "@types/react": "^19.0.4", - "@types/react-dom": "^19.0.2", - "eslint": "^9.17.0", - "eslint-config-next": "^15.1.4", - "typescript": "^5.7.2", - "vitest": "^2.1.8" - } -} diff --git a/runtime/nianxx-play/server.js b/runtime/nianxx-play/server.js deleted file mode 100644 index ec7bf25..0000000 --- a/runtime/nianxx-play/server.js +++ /dev/null @@ -1,38 +0,0 @@ -const path = require('path') - -const dir = path.join(__dirname) - -process.env.NODE_ENV = 'production' -process.chdir(__dirname) - -const currentPort = parseInt(process.env.PORT, 10) || 3000 -const hostname = process.env.HOSTNAME || '0.0.0.0' - -let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10) -const nextConfig = {"env":{},"webpack":null,"eslint":{"ignoreDuringBuilds":false},"typescript":{"ignoreBuildErrors":false,"tsconfigPath":"tsconfig.json"},"typedRoutes":false,"distDir":"./.next","cleanDistDir":true,"assetPrefix":"","cacheMaxMemorySize":52428800,"configOrigin":"next.config.ts","useFileSystemPublicRoutes":true,"generateEtags":true,"pageExtensions":["tsx","ts","jsx","js"],"poweredByHeader":true,"compress":true,"images":{"deviceSizes":[640,750,828,1080,1200,1920,2048,3840],"imageSizes":[16,32,48,64,96,128,256,384],"path":"/_next/image","loader":"default","loaderFile":"","domains":[],"disableStaticImages":false,"minimumCacheTTL":60,"formats":["image/webp"],"maximumResponseBody":50000000,"dangerouslyAllowSVG":false,"contentSecurityPolicy":"script-src 'none'; frame-src 'none'; sandbox;","contentDispositionType":"attachment","remotePatterns":[{"protocol":"https","hostname":"**.aliyuncs.com"}],"unoptimized":false},"devIndicators":{"position":"bottom-left"},"onDemandEntries":{"maxInactiveAge":60000,"pagesBufferLength":5},"amp":{"canonicalBase":""},"basePath":"","sassOptions":{},"trailingSlash":false,"i18n":null,"productionBrowserSourceMaps":false,"excludeDefaultMomentLocales":true,"serverRuntimeConfig":{},"publicRuntimeConfig":{},"reactProductionProfiling":false,"reactStrictMode":null,"reactMaxHeadersLength":6000,"httpAgentOptions":{"keepAlive":true},"logging":{},"compiler":{},"expireTime":31536000,"staticPageGenerationTimeout":60,"output":"standalone","modularizeImports":{"@mui/icons-material":{"transform":"@mui/icons-material/{{member}}"},"lodash":{"transform":"lodash/{{member}}"}},"outputFileTracingRoot":"/Users/inmanx/Documents/NianxxPlay","experimental":{"useSkewCookie":false,"cacheLife":{"default":{"stale":300,"revalidate":900,"expire":4294967294},"seconds":{"stale":30,"revalidate":1,"expire":60},"minutes":{"stale":300,"revalidate":60,"expire":3600},"hours":{"stale":300,"revalidate":3600,"expire":86400},"days":{"stale":300,"revalidate":86400,"expire":604800},"weeks":{"stale":300,"revalidate":604800,"expire":2592000},"max":{"stale":300,"revalidate":2592000,"expire":4294967294}},"cacheHandlers":{},"cssChunking":true,"multiZoneDraftMode":false,"appNavFailHandling":false,"prerenderEarlyExit":true,"serverMinification":true,"serverSourceMaps":false,"linkNoTouchStart":false,"caseSensitiveRoutes":false,"clientSegmentCache":false,"clientParamParsing":false,"dynamicOnHover":false,"preloadEntriesOnStart":true,"clientRouterFilter":true,"clientRouterFilterRedirects":false,"fetchCacheKeyPrefix":"","middlewarePrefetch":"flexible","optimisticClientCache":true,"manualClientBasePath":false,"cpus":9,"memoryBasedWorkersCount":false,"imgOptConcurrency":null,"imgOptTimeoutInSeconds":7,"imgOptMaxInputPixels":268402689,"imgOptSequentialRead":null,"imgOptSkipMetadata":null,"isrFlushToDisk":true,"workerThreads":false,"optimizeCss":false,"nextScriptWorkers":false,"scrollRestoration":false,"externalDir":false,"disableOptimizedLoading":false,"gzipSize":true,"craCompat":false,"esmExternals":true,"fullySpecified":false,"swcTraceProfiling":false,"forceSwcTransforms":false,"largePageDataBytes":128000,"typedEnv":false,"parallelServerCompiles":false,"parallelServerBuildTraces":false,"ppr":false,"authInterrupts":false,"webpackMemoryOptimizations":false,"optimizeServerReact":true,"viewTransition":false,"routerBFCache":false,"removeUncaughtErrorAndRejectionListeners":false,"validateRSCRequestHeaders":false,"staleTimes":{"dynamic":0,"static":300},"serverComponentsHmrCache":true,"staticGenerationMaxConcurrency":8,"staticGenerationMinPagesPerWorker":25,"cacheComponents":false,"inlineCss":false,"useCache":false,"globalNotFound":false,"devtoolSegmentExplorer":true,"browserDebugInfoInTerminal":false,"optimizeRouterScrolling":false,"middlewareClientMaxBodySize":10485760,"optimizePackageImports":["lucide-react","date-fns","lodash-es","ramda","antd","react-bootstrap","ahooks","@ant-design/icons","@headlessui/react","@headlessui-float/react","@heroicons/react/20/solid","@heroicons/react/24/solid","@heroicons/react/24/outline","@visx/visx","@tremor/react","rxjs","@mui/material","@mui/icons-material","recharts","react-use","effect","@effect/schema","@effect/platform","@effect/platform-node","@effect/platform-browser","@effect/platform-bun","@effect/sql","@effect/sql-mssql","@effect/sql-mysql2","@effect/sql-pg","@effect/sql-sqlite-node","@effect/sql-sqlite-bun","@effect/sql-sqlite-wasm","@effect/sql-sqlite-react-native","@effect/rpc","@effect/rpc-http","@effect/typeclass","@effect/experimental","@effect/opentelemetry","@material-ui/core","@material-ui/icons","@tabler/icons-react","mui-core","react-icons/ai","react-icons/bi","react-icons/bs","react-icons/cg","react-icons/ci","react-icons/di","react-icons/fa","react-icons/fa6","react-icons/fc","react-icons/fi","react-icons/gi","react-icons/go","react-icons/gr","react-icons/hi","react-icons/hi2","react-icons/im","react-icons/io","react-icons/io5","react-icons/lia","react-icons/lib","react-icons/lu","react-icons/md","react-icons/pi","react-icons/ri","react-icons/rx","react-icons/si","react-icons/sl","react-icons/tb","react-icons/tfi","react-icons/ti","react-icons/vsc","react-icons/wi"],"trustHostHeader":false,"isExperimentalCompile":false},"htmlLimitedBots":"[\\w-]+-Google|Google-[\\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight","bundlePagesRouterDependencies":false,"configFileName":"next.config.ts","turbopack":{"root":"/Users/inmanx/Documents/NianxxPlay"}} - -process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig) - -require('next') -const { startServer } = require('next/dist/server/lib/start-server') - -if ( - Number.isNaN(keepAliveTimeout) || - !Number.isFinite(keepAliveTimeout) || - keepAliveTimeout < 0 -) { - keepAliveTimeout = undefined -} - -startServer({ - dir, - isDev: false, - config: nextConfig, - hostname, - port: currentPort, - allowRetry: false, - keepAliveTimeout, -}).catch((err) => { - console.error(err); - process.exit(1); -}); \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..e2bc4e0 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if ! command -v docker >/dev/null 2>&1; then + echo "[deploy] Docker is required but was not found." + exit 1 +fi + +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE=(docker-compose) +else + echo "[deploy] Docker Compose is required but was not found." + exit 1 +fi + +if [ ! -f .env.local ]; then + cp .env.example .env.local + echo "[deploy] Created .env.local from .env.example" + echo "[deploy] Real generation requires API keys in .env.local. Empty keys keep mock/local flows available." +fi + +mkdir -p .runtime/data .runtime/uploads .runtime/generated-results + +if [ -z "${APP_PORT:-}" ] && [ -f .env.local ]; then + APP_PORT_FROM_FILE="$(sed -n 's/^APP_PORT=//p' .env.local | tail -n 1)" + APP_PORT_FROM_FILE="${APP_PORT_FROM_FILE//\"/}" + if [ -n "$APP_PORT_FROM_FILE" ]; then + export APP_PORT="$APP_PORT_FROM_FILE" + fi +fi + +"${COMPOSE[@]}" up -d --build +"${COMPOSE[@]}" ps + +echo "[deploy] 智念AIGC平台 is starting at http://127.0.0.1:${APP_PORT:-3000}" +echo "[deploy] If this is a public server, set NEXT_PUBLIC_APP_URL in .env.local to your domain." +echo "[deploy] Web service and zhinian-worker are both managed by Docker Compose." diff --git a/scripts/health-check.mjs b/scripts/health-check.mjs index 8780d5e..ecc605a 100644 --- a/scripts/health-check.mjs +++ b/scripts/health-check.mjs @@ -1,6 +1,6 @@ -const port = process.env.PORT || process.env.NIANXX_PLAY_PORT || '3000'; +const port = process.env.PORT || process.env.APP_PORT || '3000'; const hostname = process.env.HOSTNAME || '127.0.0.1'; -const baseUrl = process.env.NIANXXPLAY_PUBLIC_BASE_URL || `http://${hostname}:${port}`; +const baseUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.ZHINIAN_PUBLIC_BASE_URL || `http://${hostname}:${port}`; const healthUrl = new URL('/api/health', baseUrl).toString(); const controller = new AbortController(); diff --git a/scripts/print-app-info.mjs b/scripts/print-app-info.mjs index b5336c4..1b5cecf 100644 --- a/scripts/print-app-info.mjs +++ b/scripts/print-app-info.mjs @@ -35,6 +35,5 @@ console.log(JSON.stringify({ '@图片/@视频/@音频 references', 'material thumbnails in chips and @ suggestions', 'shared prompt assembly for image and video' - ], - legacyRuntime: 'runtime/nianxx-play is kept as a reference artifact only' + ] }, null, 2)); diff --git a/scripts/print-runtime-info.mjs b/scripts/print-runtime-info.mjs deleted file mode 100644 index ac4f611..0000000 --- a/scripts/print-runtime-info.mjs +++ /dev/null @@ -1,57 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const runtimeDir = join(rootDir, 'runtime', 'nianxx-play'); - -function readJson(relativePath) { - return JSON.parse(readFileSync(join(runtimeDir, relativePath), 'utf8')); -} - -const manifest = readJson('bundle-manifest.json'); -const pkg = readJson('package.json'); -const paths = readJson('.next/server/app-paths-manifest.json'); -const modes = readJson('content/seedance-starter/creation-modes.json'); -const catalog = readJson('content/seedance-starter/catalog.json'); -const planningCases = readJson('content/planning-cases.json'); - -const modeCounts = {}; -for (const item of catalog.cases || []) { - modeCounts[item.mode] = (modeCounts[item.mode] || 0) + 1; -} - -console.log(JSON.stringify({ - project: 'Zhinian Creation Assistant', - packageName: 'zhinian-creation-assistant', - runtime: { - appId: manifest.appId, - name: manifest.name, - version: manifest.version, - bundledAt: manifest.bundledAt, - entry: manifest.entry, - sizeBytes: manifest.sizeBytes, - }, - package: { - name: pkg.name, - version: pkg.version, - dependencies: pkg.dependencies, - }, - routes: Object.keys(paths).sort(), - creationModes: modes.map((mode) => ({ - id: mode.id, - name: mode.name, - editorType: mode.editorType, - assetSlots: mode.assetSlots, - })), - catalog: { - totalCases: (catalog.cases || []).length, - modeCounts, - }, - planningCases: planningCases.map((item) => ({ - id: item.id, - title: item.title, - orientation: item.orientation, - })), -}, null, 2)); diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..78d1cfb --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if [ ! -f .env.local ]; then + cp .env.example .env.local + echo "[setup] Created .env.local from .env.example" +else + echo "[setup] .env.local already exists" +fi + +mkdir -p .runtime/data .runtime/uploads .runtime/generated-results +echo "[setup] Runtime directories are ready" + +if command -v npm >/dev/null 2>&1; then + npm install + echo "[setup] Dependencies installed" +else + echo "[setup] npm was not found. Skip dependency install." +fi + +echo "[setup] Done. Start locally with:" +echo " npm run dev -- --hostname 127.0.0.1 --port 3000" diff --git a/scripts/start-runtime.mjs b/scripts/start-runtime.mjs deleted file mode 100644 index a056f72..0000000 --- a/scripts/start-runtime.mjs +++ /dev/null @@ -1,107 +0,0 @@ -import { spawn } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const runtimeDir = join(rootDir, 'runtime', 'nianxx-play'); -const serverPath = join(runtimeDir, 'server.js'); - -function parseEnvValue(raw) { - const value = raw.trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - try { - return JSON.parse(value); - } catch { - return value.slice(1, -1); - } - } - return value; -} - -function parseEnvFile(filePath) { - if (!existsSync(filePath)) return {}; - const env = {}; - const raw = readFileSync(filePath, 'utf8'); - for (const line of raw.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); - if (!match) continue; - env[match[1]] = parseEnvValue(match[2]); - } - return env; -} - -function loadEnv() { - const envFiles = [ - join(rootDir, '.env'), - join(rootDir, '.env.local'), - ]; - if (process.env.NIANXXPLAY_LOAD_BUNDLED_ENV === '1') { - envFiles.push(join(runtimeDir, '.env.runtime')); - } - - const fileEnv = {}; - for (const file of envFiles) { - Object.assign(fileEnv, parseEnvFile(file)); - } - return { ...fileEnv, ...process.env }; -} - -if (!existsSync(serverPath)) { - console.error(`Zhinian Creation Assistant runtime entry not found: ${serverPath}`); - process.exit(1); -} - -const baseEnv = loadEnv(); -const port = baseEnv.PORT || baseEnv.NIANXX_PLAY_PORT || '3000'; -const hostname = baseEnv.HOSTNAME || '127.0.0.1'; -const runtimeRoot = baseEnv.NIANXXPLAY_RUNTIME_DIR || join(rootDir, '.runtime'); -const dataDir = baseEnv.NIANXXPLAY_DATA_DIR || join(runtimeRoot, 'data'); -const uploadDir = baseEnv.NIANXXPLAY_UPLOAD_DIR || join(runtimeRoot, 'uploads'); -const resultDir = baseEnv.NIANXXPLAY_RESULT_DIR || join(runtimeRoot, 'generated-results'); - -mkdirSync(dataDir, { recursive: true }); -mkdirSync(uploadDir, { recursive: true }); -mkdirSync(resultDir, { recursive: true }); - -const env = { - ...baseEnv, - PORT: String(port), - HOSTNAME: hostname, - NODE_ENV: 'production', - NEXT_TELEMETRY_DISABLED: '1', - NIANXXPLAY_RUNTIME_DIR: runtimeRoot, - NIANXXPLAY_DATA_DIR: dataDir, - NIANXXPLAY_UPLOAD_DIR: uploadDir, - NIANXXPLAY_RESULT_DIR: resultDir, - NIANXXPLAY_PUBLIC_BASE_URL: baseEnv.NIANXXPLAY_PUBLIC_BASE_URL || `http://${hostname}:${port}`, - NIANXXPLAY_DESKTOP_MANAGED: '1', -}; - -console.log(`[Zhinian Creation Assistant] Starting on http://${hostname}:${port}`); -console.log(`[Zhinian Creation Assistant] Runtime data: ${runtimeRoot}`); - -const child = spawn(process.execPath, [serverPath], { - cwd: runtimeDir, - env, - stdio: 'inherit', -}); - -function stop(signal) { - if (!child.killed) child.kill(signal); -} - -process.on('SIGINT', () => stop('SIGINT')); -process.on('SIGTERM', () => stop('SIGTERM')); - -child.on('exit', (code, signal) => { - if (signal) { - process.exit(0); - } - process.exit(code ?? 0); -}); diff --git a/scripts/worker.mjs b/scripts/worker.mjs new file mode 100755 index 0000000..09e3b88 --- /dev/null +++ b/scripts/worker.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +const baseUrl = (process.env.ZHINIAN_WORKER_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || "http://127.0.0.1:3000").replace(/\/$/, ""); +const token = process.env.ZHINIAN_INTERNAL_WORKER_TOKEN || ""; +const intervalMs = positiveInt(process.env.ZHINIAN_WORKER_INTERVAL_MS, 5000); +const limit = positiveInt(process.env.ZHINIAN_WORKER_BATCH_SIZE, 3); +const once = process.argv.includes("--once"); +const workerId = process.env.ZHINIAN_WORKER_ID || `worker-${Math.random().toString(16).slice(2)}`; + +async function tick() { + const response = await fetch(`${baseUrl}/api/internal/worker/tick`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { "X-Zhinian-Worker-Token": token } : {}) + }, + body: JSON.stringify({ workerId, limit }) + }); + const text = await response.text(); + if (!response.ok) throw new Error(`Worker tick failed: ${response.status} ${text}`); + return text ? JSON.parse(text) : {}; +} + +async function run() { + do { + try { + const result = await tick(); + console.log(`[zhinian-worker] claimed=${result.claimed || 0} worker=${result.workerId || workerId}`); + } catch (error) { + console.error(`[zhinian-worker] ${error instanceof Error ? error.message : String(error)}`); + if (once) process.exitCode = 1; + } + if (!once) await sleep(intervalMs); + } while (!once); +} + +function positiveInt(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +run(); diff --git a/supabase/schema.sql b/supabase/schema.sql index 7cb39d0..ff1725b 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -15,6 +15,7 @@ create table if not exists assets ( create table if not exists generation_jobs ( id text primary key, owner_id text not null, + external_client_id text, capability text not null, provider text not null, req_key text not null, @@ -28,10 +29,38 @@ create table if not exists generation_jobs ( response_payload jsonb, error jsonb, retry_of text, + idempotency_key text, + idempotency_fingerprint text, + priority integer not null default 0, + attempts integer not null default 0, + max_attempts integer not null default 3, + scheduled_at timestamptz not null default now(), + locked_at timestamptz, + locked_by text, + started_at timestamptz, + completed_at timestamptz, + webhook_url text, + webhook_attempts integer not null default 0, + webhook_last_status jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); +alter table generation_jobs add column if not exists external_client_id text; +alter table generation_jobs add column if not exists idempotency_key text; +alter table generation_jobs add column if not exists idempotency_fingerprint text; +alter table generation_jobs add column if not exists priority integer not null default 0; +alter table generation_jobs add column if not exists attempts integer not null default 0; +alter table generation_jobs add column if not exists max_attempts integer not null default 3; +alter table generation_jobs add column if not exists scheduled_at timestamptz not null default now(); +alter table generation_jobs add column if not exists locked_at timestamptz; +alter table generation_jobs add column if not exists locked_by text; +alter table generation_jobs add column if not exists started_at timestamptz; +alter table generation_jobs add column if not exists completed_at timestamptz; +alter table generation_jobs add column if not exists webhook_url text; +alter table generation_jobs add column if not exists webhook_attempts integer not null default 0; +alter table generation_jobs add column if not exists webhook_last_status jsonb; + create table if not exists usage_events ( id text primary key, owner_id text not null, @@ -56,4 +85,47 @@ create table if not exists projects ( create index if not exists assets_owner_created_idx on assets(owner_id, created_at desc); create index if not exists generation_jobs_owner_created_idx on generation_jobs(owner_id, created_at desc); create index if not exists generation_jobs_status_idx on generation_jobs(status); +create index if not exists generation_jobs_claim_idx on generation_jobs(status, scheduled_at, locked_at, priority desc); +create index if not exists generation_jobs_external_client_idx on generation_jobs(owner_id, external_client_id, created_at desc); +create unique index if not exists generation_jobs_idempotency_idx + on generation_jobs(owner_id, external_client_id, idempotency_key) + where external_client_id is not null and idempotency_key is not null; create index if not exists usage_events_owner_created_idx on usage_events(owner_id, created_at desc); + +create or replace function claim_generation_jobs( + p_worker_id text, + p_limit integer default 1, + p_lock_timeout_seconds integer default 300 +) +returns setof generation_jobs +language plpgsql +as $$ +declare + v_now timestamptz := now(); +begin + return query + with candidates as ( + select id + from generation_jobs + where status in ('queued', 'running') + and coalesce(scheduled_at, created_at) <= v_now + and ( + locked_at is null + or locked_at < v_now - make_interval(secs => p_lock_timeout_seconds) + ) + order by coalesce(priority, 0) desc, coalesce(scheduled_at, created_at) asc, created_at asc + limit greatest(1, least(coalesce(p_limit, 1), 20)) + for update skip locked + ), + updated as ( + update generation_jobs + set locked_at = v_now, + locked_by = p_worker_id, + started_at = coalesce(generation_jobs.started_at, v_now), + updated_at = v_now + where id in (select id from candidates) + returning generation_jobs.* + ) + select * from updated; +end; +$$; diff --git a/task_plan.md b/task_plan.md index 78ea1c2..95d45b8 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,7 +4,7 @@ Add EvoLink GPT Image 2 as a selectable image creation engine in the settings flow, while preserving the existing Jimeng/Volcengine image engine and the current task/asset workflow. ## Current Phase -Complete - latest update: product UI/UX polish, Seedance limits, and 智念AIGC平台 branding +Complete - latest update: Task management and public API v1 ## Phases @@ -60,6 +60,23 @@ Complete - latest update: product UI/UX polish, Seedance limits, and 智念AIGC - [x] Verify desktop and mobile header rendering - **Status:** complete +### Phase 9: Server Deployment Support +- [x] Add Dockerfile for production image builds +- [x] Add Docker Compose service with runtime persistence and healthcheck +- [x] Add setup/deploy scripts +- [x] Document one-command server deployment in Chinese README +- [x] Verify shell syntax, unit tests, production build, and local health +- **Status:** complete + +### Phase 10: Task Management and Public API v1 +- [x] Extend generation job fields for task ownership, idempotency, locking, retry, timing, and webhook delivery +- [x] Split task creation from provider execution so page/API submits enqueue only +- [x] Add task management service, worker loop, API key auth, webhook delivery, and OpenAPI output +- [x] Add `/api/v1` capabilities, assets, jobs, cancel, and openapi routes +- [x] Add Docker Compose worker service, npm worker script, docs, and env examples +- [x] Add focused tests and run verification commands +- **Status:** complete + ## Key Questions 1. How should the selected image engine be stored and exposed in settings? 2. Which current capabilities should EvoLink handle first? @@ -77,6 +94,9 @@ Complete - latest update: product UI/UX polish, Seedance limits, and 智念AIGC | Follow official Seedance 2.0 duration range `4~15` seconds in the UI | Prevents the user from selecting values the API rejects | | Preserve `-1` Seedance auto duration only in backend/env normalization, not in the default UI dropdown | Keeps the UI predictable while still supporting advanced configuration | | Use the black/blue transparent NIANXX logo on the light top bar | Makes the logo visible without adding a frame that changes the brand feel | +| Use Docker Compose as the primary server deployment path | Gives server operators one command, persistent local runtime data, and a restart policy | +| Implement multi-task support as task management, not an external message queue | Matches user preference and keeps deployment simpler for this server product | +| Use API Key auth for public API v1 | Fastest stable server-to-server integration model for other AI systems | ## Errors Encountered | Error | Attempt | Resolution | @@ -90,6 +110,8 @@ Complete - latest update: product UI/UX polish, Seedance limits, and 智念AIGC | `npm run build` failed after Seedance settings update because TypeScript narrowed fast-model resolution choices too aggressively | 1 | Changed resolution `includes` checks to readonly string arrays and rebuilt successfully | | Running `npm run build` while an old dev server was active caused stale Next dev chunks in prior verification | 1 | Stop dev server before production builds, then restart it afterward | | White transparent logo was invisible on the light top bar unless wrapped in a dark frame | 1 | Switched to the black/blue logo variant, generated a cropped transparent PNG, and removed frame/background styling | +| Current local machine does not expose Docker CLI | 1 | Verified script syntax, Next build, tests, and health locally; Docker build should be run on the deployment server | +| Docker CLI is still unavailable while validating Phase 10 | 1 | Verified npm tests, production build, local health, API v1 calls, and worker tick locally; Compose container startup should be validated on the deployment server | ## Notes - EvoLink docs: submit `POST /v1/images/generations`, query `GET /v1/tasks/{task_id}`, completed task exposes `results[]`. @@ -99,3 +121,7 @@ Complete - latest update: product UI/UX polish, Seedance limits, and 智念AIGC - Before production build verification, stop the dev server first to avoid stale `.next` dev chunk references. - Latest product name is `智念AIGC平台`. - Current header logo asset is `public/logo/zhinian-logo.png`, generated from `/Users/inmanx/Documents/icon/logo/2d5b992caa14db16f594c4933e92e37e.png`. +- Server one-command deployment entrypoint is `bash scripts/deploy.sh`. +- Docker Compose persists local uploads/results/state through the bind mount `./.runtime:/app/.runtime`. +- Public API v1 endpoints are under `/api/v1` and require `ZHINIAN_API_KEYS`. +- Task processing is handled by `npm run worker` or the `zhinian-worker` Compose service through `/api/internal/worker/tick`. diff --git a/tests/data-store-concurrency.test.ts b/tests/data-store-concurrency.test.ts index b978ed6..76392e3 100644 --- a/tests/data-store-concurrency.test.ts +++ b/tests/data-store-concurrency.test.ts @@ -17,16 +17,16 @@ let previousSupabaseKey: string | undefined; describe("local data store concurrency", () => { beforeEach(async () => { runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-store-")); - previousRuntimeDir = process.env.NIANXXPLAY_RUNTIME_DIR; + previousRuntimeDir = process.env.ZHINIAN_RUNTIME_DIR; previousSupabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; previousSupabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - process.env.NIANXXPLAY_RUNTIME_DIR = runtimeDir; + process.env.ZHINIAN_RUNTIME_DIR = runtimeDir; delete process.env.NEXT_PUBLIC_SUPABASE_URL; delete process.env.SUPABASE_SERVICE_ROLE_KEY; }); afterEach(async () => { - restoreEnv("NIANXXPLAY_RUNTIME_DIR", previousRuntimeDir); + restoreEnv("ZHINIAN_RUNTIME_DIR", previousRuntimeDir); restoreEnv("NEXT_PUBLIC_SUPABASE_URL", previousSupabaseUrl); restoreEnv("SUPABASE_SERVICE_ROLE_KEY", previousSupabaseKey); await rm(runtimeDir, { force: true, recursive: true }); diff --git a/tests/task-management.test.ts b/tests/task-management.test.ts new file mode 100644 index 0000000..1dcd80b --- /dev/null +++ b/tests/task-management.test.ts @@ -0,0 +1,155 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + authenticatePublicApiRequest, + PublicApiAuthError, + type PublicApiClient +} from "@/lib/server/public-api-auth"; +import { createPublicGenerationJob, PublicApiConflictError } from "@/lib/server/public-api-jobs"; +import { claimGenerationJobs, createGenerationJob, getGenerationJob, listGenerationJobs } from "@/lib/server/data-store"; +import { runWorkerTick } from "@/lib/server/task-manager"; +import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; +import { signWebhookBody } from "@/lib/server/webhook"; + +let runtimeDir = ""; +const previousEnv = new Map(); +const envNames = [ + "ZHINIAN_RUNTIME_DIR", + "NEXT_PUBLIC_SUPABASE_URL", + "SUPABASE_SERVICE_ROLE_KEY", + "ZHINIAN_API_KEYS", + "JIMENG_VISUAL_MOCK", + "VOLCENGINE_ACCESS_KEY_ID", + "VOLCENGINE_SECRET_ACCESS_KEY", + "ZHINIAN_WEBHOOK_SECRET" +]; + +describe("task management and public API helpers", () => { + beforeEach(async () => { + runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-tasks-")); + for (const name of envNames) previousEnv.set(name, process.env[name]); + process.env.ZHINIAN_RUNTIME_DIR = runtimeDir; + process.env.ZHINIAN_API_KEYS = "agent-a:secret-a,agent-b:secret-b"; + process.env.JIMENG_VISUAL_MOCK = "true"; + delete process.env.NEXT_PUBLIC_SUPABASE_URL; + delete process.env.SUPABASE_SERVICE_ROLE_KEY; + delete process.env.VOLCENGINE_ACCESS_KEY_ID; + delete process.env.VOLCENGINE_SECRET_ACCESS_KEY; + }); + + afterEach(async () => { + for (const name of envNames) restoreEnv(name, previousEnv.get(name)); + previousEnv.clear(); + await rm(runtimeDir, { force: true, recursive: true }); + }); + + it("authenticates public API requests with bearer and header keys", () => { + expect(authenticatePublicApiRequest(new Request("http://local.test", { + headers: { Authorization: "Bearer secret-a" } + })).id).toBe("agent-a"); + expect(authenticatePublicApiRequest(new Request("http://local.test", { + headers: { "X-Zhinian-Api-Key": "secret-b" } + })).id).toBe("agent-b"); + expect(() => authenticatePublicApiRequest(new Request("http://local.test"))).toThrow(PublicApiAuthError); + }); + + it("deduplicates public job creation by idempotency key and rejects conflicts", async () => { + const client: PublicApiClient = { id: "agent-a", key: "secret-a" }; + const request = new Request("http://local.test/api/v1/jobs", { + headers: { "Idempotency-Key": "idem-1" } + }); + const first = await createPublicGenerationJob({ + client, + request, + origin: "http://local.test", + body: { + capability: "image.generate", + prompt: "生成一张专业产品主图" + } + }); + const second = await createPublicGenerationJob({ + client, + request, + origin: "http://local.test", + body: { + capability: "image.generate", + prompt: "生成一张专业产品主图" + } + }); + expect(second.reused).toBe(true); + expect(second.job.id).toBe(first.job.id); + await expect(createPublicGenerationJob({ + client, + request, + origin: "http://local.test", + body: { + capability: "image.generate", + prompt: "不同的提示词" + } + })).rejects.toBeInstanceOf(PublicApiConflictError); + }); + + it("claims local jobs without duplicate ownership", async () => { + await Promise.all(Array.from({ length: 6 }, (_, index) => createGenerationJob({ + ownerId: DEFAULT_OWNER_ID, + capability: "image.generate", + provider: "mock", + reqKey: "jimeng_seedream46_cvtob", + status: "queued", + prompt: `job ${index}`, + inputAssetIds: [], + inputUrls: [], + outputAssetIds: [], + requestPayload: { index }, + priority: index + }))); + + const [left, right] = await Promise.all([ + claimGenerationJobs({ workerId: "worker-a", limit: 3 }), + claimGenerationJobs({ workerId: "worker-b", limit: 3 }) + ]); + const claimedIds = [...left, ...right].map((job) => job.id); + expect(new Set(claimedIds).size).toBe(6); + expect(await claimGenerationJobs({ workerId: "worker-c", limit: 1 })).toHaveLength(0); + }); + + it("processes a mock queued job to a terminal result through the worker tick", async () => { + const job = await createPublicGenerationJob({ + client: { id: "agent-a", key: "secret-a" }, + request: new Request("http://local.test/api/v1/jobs"), + origin: "http://local.test", + body: { + capability: "image.generate", + prompt: "生成一张适合社媒传播的品牌主视觉" + } + }); + expect(job.job.status).toBe("queued"); + const tick = await runWorkerTick({ + workerId: "test-worker", + origin: "http://local.test", + limit: 1 + }); + expect(tick.claimed).toBe(1); + const stored = await getGenerationJob(job.job.id); + expect(stored?.status).toBe("succeeded"); + expect(stored?.completedAt).toBeTruthy(); + expect(stored?.outputAssetIds.length).toBe(1); + expect(await listGenerationJobs(DEFAULT_OWNER_ID, 10)).toHaveLength(1); + }); + + it("signs webhook bodies with the configured secret", () => { + expect(signWebhookBody("{\"ok\":true}", "secret")).toBe( + "sha256=f6b4a2841c93f8bf2fb8f2c13d8fb0b6c8e8019f09ee405d248daa8385fad638" + ); + }); +}); + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +}