commit f9c3393f848cb366c1682038fe688bad9216563e Author: inman Date: Fri May 29 10:26:02 2026 +0800 Initial 智念AIGC platform diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eafdc7a --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# Copy this file to .env.local when you need real generation. +# Do not commit filled secret values. + +# Local server +PORT=3000 +HOSTNAME=127.0.0.1 +NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 + +# Supabase SaaS data layer. If empty, the app uses .runtime/data/web-app-state.json. +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# Image creation engines by capability: jimeng or evolink. +IMAGE_GENERATE_ENGINE=jimeng +IMAGE_INPAINT_ENGINE=jimeng + +# Volcengine Visual API for Jimeng image capabilities. +VOLCENGINE_ACCESS_KEY_ID= +VOLCENGINE_SECRET_ACCESS_KEY= +VOLCENGINE_REGION=cn-north-1 +VOLCENGINE_SERVICE=cv +VOLCENGINE_VISUAL_ENDPOINT=https://visual.volcengineapi.com +JIMENG_IMAGE_GENERATE_46_REQ_KEY=jimeng_seedream46_cvtob +JIMENG_IMAGE_INPAINT_REQ_KEY=jimeng_image2image_dream_inpaint +JIMENG_IMAGE_UPSCALE_REQ_KEY=jimeng_i2i_seed3_tilesr_cvtob +# auto mocks image jobs when Volcengine credentials are missing. +JIMENG_VISUAL_MOCK=auto + +# EvoLink GPT Image 2 relay for image generation and inpainting. +EVOLINK_API_KEY= +EVOLINK_BASE_URL=https://api.evolink.ai +EVOLINK_IMAGE_MODEL=gpt-image-2 +EVOLINK_IMAGE_QUALITY=medium +EVOLINK_IMAGE_RESOLUTION=2K +# auto mocks EvoLink image jobs when EVOLINK_API_KEY is missing. +EVOLINK_MOCK=auto + +# Seedance / Volcengine Ark +SEEDANCE_API_KEY= +SEEDANCE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +SEEDANCE_MODEL=doubao-seedance-2-0-260128 +SEEDANCE_RATIO=9:16 +# Seedance 2.0 duration supports integer seconds from 4 to 15, or -1 for model auto. +SEEDANCE_DURATION=5 +SEEDANCE_RESOLUTION=720p +# auto mocks video jobs when Seedance credentials are missing. +SEEDANCE_MOCK=auto + +# Aliyun OSS for uploaded/reference assets that Seedance can read. +ALI_OSS_ENDPOINT= +ALI_OSS_BUCKET= +ALI_OSS_ACCESS_KEY_ID= +ALI_OSS_ACCESS_KEY_SECRET= +ALI_OSS_PREFIX=nianxxplay +ALI_OSS_PUBLIC_BASE_URL= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58b8daf --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.runtime/ +.next/ +.env +.env.local +.env.*.local +*.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/README.md b/README.md new file mode 100644 index 0000000..43e070f --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# 智念AIGC平台 + +这是 `智念AIGC平台` 的 Web 极简 MVP。当前产品只保留核心闭环:统一创作图片/视频、查看结果、局部重绘、智能超清和必要设置。 + +旧的 `runtime/nianxx-play` standalone 运行包只作为旧流程、样例素材和 Seedance 参考,不再作为主应用入口。仓库只保留旧运行时元数据;大体积本地媒体与依赖不纳入代码提交。 + +## 启动 + +```bash +cd /Users/inmanx/Documents/zhinian-creation-assistant +npm install +npm run dev -- --hostname 127.0.0.1 --port 3000 +``` + +默认访问: + +```text +http://127.0.0.1:3000 +``` + +常用命令: + +```bash +npm start +npm run build +npm test +npm run health +npm run info +``` + +`npm start` 会自动先执行一次生产构建,再启动 `http://127.0.0.1:3000`;开发调试建议继续使用 `npm run dev`。 + +旧 runtime 仍可手动启动: + +```bash +npm run legacy:start +``` + +## Web MVP 信息架构 + +- `/` 自动跳转到 `/create` +- `/create` 创作,合并图片、视频、局部重绘、智能超清 +- `/assets` 结果,保留历史资产和历史生成任务 +- `/image-edit` 兼容旧入口,自动跳转到创作页的局部重绘 +- `/settings` 设置 + +主导航只保留创作、结果、设置,不包含工作台、项目、模板中心、Billing 或桌面端入口。 + +## 图片创作引擎 + +图片生成和局部重绘支持在设置页「状态」里按功能切换创作引擎: + +- `jimeng`:默认引擎,走火山 Visual 即梦能力。 +- `evolink`:走 EvoLink GPT Image 2 中转站,提交任务后轮询 EvoLink task 结果。 + +智能超清仍走即梦超清能力。 + +## 即梦图片能力 + +V1 接入三类能力: + +- `image.generate`:即梦图片生成 4.6,默认 `req_key=jimeng_seedream46_cvtob` +- `image.inpaint`:交互编辑 inpainting,默认 `req_key=jimeng_image2image_dream_inpaint` +- `image.upscale`:智能超清,默认 `req_key=jimeng_i2i_seed3_tilesr_cvtob` + +后端统一走火山 Visual 异步任务: + +- 提交:`CVSync2AsyncSubmitTask` +- 查询:`CVSync2AsyncGetResult` + +未配置火山密钥时,`JIMENG_VISUAL_MOCK=auto` 会自动使用 mock 图,方便先跑通产品流。 + +## EvoLink 图片能力 + +设置 `IMAGE_GENERATE_ENGINE=evolink` 或 `IMAGE_INPAINT_ENGINE=evolink` 后,对应功能会使用 EvoLink: + +- 提交:`POST /v1/images/generations` +- 查询:`GET /v1/tasks/{task_id}` +- 默认模型:`gpt-image-2` + +未配置 `EVOLINK_API_KEY` 且 `EVOLINK_MOCK=auto` 时,会自动使用 mock 图。 + +## AI 生成台与提示词编排 + +`/create` 是新的统一生成入口,按即梦“图片/视频能力在同一个创作产品内切换”的方式组织: + +- 一个提示词框:图片和视频都在同一创作面板内编辑最终提示词。 +- 模式切换:图片模式走即梦图片生成 4.6;视频模式走 Seedance 视频生成。 +- 素材统一上传:一个入口上传图片、视频或音频,不再拆分参考图、主体、分镜等栏目。 +- `@素材` 引用:上传后自动绑定为 `@图片1`、`@视频1`、`@音频1`,chip 和 @ 候选项都显示缩略图。 +- 提示词校验:通过 `/api/prompt/assemble` 检查提示词中引用的素材是否已绑定。 +- 工作台只负责生成输入和提交,不展示任务列表或最近结果。 +- 结果保存:图片和视频生成结果会写入资产记录,并在 `/assets` 保留历史任务与历史资产。 + +未配置 `SEEDANCE_API_KEY` 时,`SEEDANCE_MOCK=auto` 会自动使用旧模板样片作为 mock 成品,方便先验收工作流。 + +## 环境变量 + +复制配置样例: + +```bash +cp .env.example .env.local +``` + +核心配置: + +- `IMAGE_GENERATE_ENGINE=jimeng` 或 `evolink` +- `IMAGE_INPAINT_ENGINE=jimeng` 或 `evolink` +- `VOLCENGINE_ACCESS_KEY_ID` +- `VOLCENGINE_SECRET_ACCESS_KEY` +- `VOLCENGINE_REGION=cn-north-1` +- `VOLCENGINE_SERVICE=cv` +- `VOLCENGINE_VISUAL_ENDPOINT=https://visual.volcengineapi.com` +- `EVOLINK_API_KEY` +- `EVOLINK_BASE_URL=https://api.evolink.ai` +- `EVOLINK_IMAGE_MODEL=gpt-image-2` +- `EVOLINK_IMAGE_QUALITY=medium` +- `EVOLINK_IMAGE_RESOLUTION=2K` +- `EVOLINK_MOCK=auto` +- `SEEDANCE_API_KEY` +- `SEEDANCE_BASE_URL` +- `SEEDANCE_MODEL` +- `SEEDANCE_RATIO`:支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive` +- `SEEDANCE_DURATION`:Seedance 2.0 支持 `4` 到 `15` 的整数秒,或 `-1` 让模型自动选择 +- `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`;Seedance 2.0 fast 不支持 `1080p` +- `SEEDANCE_MOCK` +- `ALI_OSS_*`:用于上传素材和生成结果转存 +- `NEXT_PUBLIC_SUPABASE_URL` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` +- `SUPABASE_SERVICE_ROLE_KEY` + +如果 Supabase 未配置,应用会使用 `.runtime/data/web-app-state.json` 做本地开发数据层。如果 OSS 未配置,上传和 mock 结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Web 路由提供访问。 + +## 数据库 + +Supabase/Postgres 表结构在: + +```text +supabase/schema.sql +``` + +当前仍保留必要数据表,供上传、生成任务和用量记录使用: + +- `assets` +- `generation_jobs` +- `usage_events` + +## API + +核心图片 API: + +- `POST /api/generations/image` +- `GET /api/generations/image` +- `GET /api/generations/image/[id]` +- `POST /api/generations/image/[id]/retry` +- `POST /api/assets/[id]/inpaint` +- `POST /api/assets/[id]/upscale` +- `POST /api/generations/video` +- `GET /api/generations/video` +- `GET /api/generations/video/[id]` +- `POST /api/prompt/assemble` +- `GET /api/assets` +- `POST /api/assets` +- `POST /api/assets/upload` + +健康检查: + +- `GET /api/health` + +## 验证 + +当前已覆盖: + +- 即梦能力矩阵:4.6/inpainting/upscale 启用 +- 即梦请求参数构造 +- 分镜提示词与 `@素材` 引用编排 +- 火山 Visual 签名 canonical request +- Next.js 生产构建 + +```bash +npm test +npm run build +npm run health +``` diff --git a/app/api/assets/[id]/inpaint/route.ts b/app/api/assets/[id]/inpaint/route.ts new file mode 100644 index 0000000..a0b3640 --- /dev/null +++ b/app/api/assets/[id]/inpaint/route.ts @@ -0,0 +1,44 @@ +import { getAsset } from "@/lib/server/data-store"; +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requestOrigin } from "@/lib/server/runtime"; +import { saveMaskDataUrl } from "@/lib/server/storage"; +import { submitImageJob } from "@/lib/server/generation-service"; + +export const runtime = "nodejs"; + +export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const asset = await getAsset(id); + if (!asset) return jsonError(new Error("Asset not found."), 404); + const body = await readJsonBody<{ + prompt?: string; + maskDataUrl?: string; + maskUrl?: string; + seed?: number; + }>(request); + let maskUrl = body.maskUrl; + let maskAssetId: string | undefined; + if (body.maskDataUrl) { + const mask = await saveMaskDataUrl({ + ownerId: asset.ownerId, + dataUrl: body.maskDataUrl, + origin: requestOrigin(request), + jobHint: asset.id + }); + maskUrl = mask.url; + maskAssetId = mask.id; + } + if (!maskUrl) throw new Error("maskDataUrl or maskUrl is required for inpainting."); + const job = await submitImageJob({ + capability: "image.inpaint", + prompt: body.prompt || "删除", + imageUrls: [asset.url, maskUrl], + inputAssetIds: [asset.id, ...(maskAssetId ? [maskAssetId] : [])], + seed: typeof body.seed === "number" ? body.seed : undefined + }, requestOrigin(request)); + return jsonOk({ job }, { status: 202 }); + } catch (error) { + return jsonError(error); + } +} diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts new file mode 100644 index 0000000..6256fa5 --- /dev/null +++ b/app/api/assets/[id]/route.ts @@ -0,0 +1,18 @@ +import { deleteAsset, getAsset } from "@/lib/server/data-store"; +import { jsonError, jsonOk } from "@/lib/server/api"; +import { deleteStoredAsset } from "@/lib/server/storage"; + +export const runtime = "nodejs"; + +export async function DELETE(_request: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const asset = await getAsset(id); + if (!asset) return jsonError("资产不存在", 404); + await deleteStoredAsset(asset); + await deleteAsset(id); + return jsonOk({ ok: true, deletedAssetId: id }); + } catch (error) { + return jsonError(error, 500); + } +} diff --git a/app/api/assets/[id]/upscale/route.ts b/app/api/assets/[id]/upscale/route.ts new file mode 100644 index 0000000..93b1eb5 --- /dev/null +++ b/app/api/assets/[id]/upscale/route.ts @@ -0,0 +1,28 @@ +import { getAsset } from "@/lib/server/data-store"; +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requestOrigin } from "@/lib/server/runtime"; +import { submitImageJob } from "@/lib/server/generation-service"; + +export const runtime = "nodejs"; + +export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const asset = await getAsset(id); + if (!asset) return jsonError(new Error("Asset not found."), 404); + const body = await readJsonBody<{ + resolution?: "4k" | "8k"; + scale?: number; + }>(request); + const job = await submitImageJob({ + capability: "image.upscale", + imageUrls: [asset.url], + inputAssetIds: [asset.id], + resolution: body.resolution === "8k" ? "8k" : "4k", + scale: typeof body.scale === "number" ? body.scale : undefined + }, requestOrigin(request)); + return jsonOk({ job }, { status: 202 }); + } catch (error) { + return jsonError(error); + } +} diff --git a/app/api/assets/route.ts b/app/api/assets/route.ts new file mode 100644 index 0000000..b045b5b --- /dev/null +++ b/app/api/assets/route.ts @@ -0,0 +1,41 @@ +import { createAsset, listAssets } from "@/lib/server/data-store"; +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; +import type { AssetKind } from "@/lib/types"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + return jsonOk({ assets: await listAssets(DEFAULT_OWNER_ID) }); + } catch (error) { + return jsonError(error, 500); + } +} + +export async function POST(request: Request) { + try { + const body = await readJsonBody<{ + url?: string; + name?: string; + kind?: AssetKind; + tags?: string[]; + source?: "upload" | "generated" | "edited" | "upscaled" | "external" | "seed"; + }>(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: body.source || "external", + tags: body.tags || ["external"], + metadata: { + registeredFrom: "api" + } + }); + return jsonOk({ asset }, { status: 201 }); + } catch (error) { + return jsonError(error); + } +} diff --git a/app/api/assets/upload/route.ts b/app/api/assets/upload/route.ts new file mode 100644 index 0000000..0ea89da --- /dev/null +++ b/app/api/assets/upload/route.ts @@ -0,0 +1,26 @@ +import { jsonError, jsonOk } from "@/lib/server/api"; +import { DEFAULT_OWNER_ID, requestOrigin } from "@/lib/server/runtime"; +import { saveUploadAsset } from "@/lib/server/storage"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + 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) => { + return saveUploadAsset({ + ownerId: DEFAULT_OWNER_ID, + bytes: Buffer.from(await file.arrayBuffer()), + fileName: file.name, + contentType: file.type || "application/octet-stream", + origin: requestOrigin(request), + tags: ["upload"] + }); + })); + return jsonOk({ assets }, { status: 201 }); + } catch (error) { + return jsonError(error); + } +} diff --git a/app/api/generations/image/[id]/retry/route.ts b/app/api/generations/image/[id]/retry/route.ts new file mode 100644 index 0000000..8e97779 --- /dev/null +++ b/app/api/generations/image/[id]/retry/route.ts @@ -0,0 +1,15 @@ +import { jsonError, jsonOk } from "@/lib/server/api"; +import { requestOrigin } from "@/lib/server/runtime"; +import { retryImageJob } from "@/lib/server/generation-service"; + +export const runtime = "nodejs"; + +export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const job = await retryImageJob(id, requestOrigin(request)); + return jsonOk({ job }, { status: 202 }); + } catch (error) { + return jsonError(error); + } +} diff --git a/app/api/generations/image/[id]/route.ts b/app/api/generations/image/[id]/route.ts new file mode 100644 index 0000000..2d80caa --- /dev/null +++ b/app/api/generations/image/[id]/route.ts @@ -0,0 +1,39 @@ +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 }> }) { + 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)); + return jsonOk({ job }); + } catch (error) { + return jsonError(error, 500); + } +} + +export async function DELETE(_request: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const job = await getGenerationJob(id); + if (!job || job.capability === "video.generate") return jsonError("任务不存在", 404); + const deletedAssetIds: string[] = []; + for (const assetId of job.outputAssetIds) { + const asset = await getAsset(assetId); + if (!asset) continue; + await deleteStoredAsset(asset); + await deleteAsset(asset.id); + deletedAssetIds.push(asset.id); + } + await deleteGenerationJob(id); + return jsonOk({ ok: true, deletedJobId: id, deletedAssetIds }); + } catch (error) { + return jsonError(error, 500); + } +} diff --git a/app/api/generations/image/route.ts b/app/api/generations/image/route.ts new file mode 100644 index 0000000..c592106 --- /dev/null +++ b/app/api/generations/image/route.ts @@ -0,0 +1,64 @@ +import { getGenerationJob, listGenerationJobs } from "@/lib/server/data-store"; +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requestOrigin } from "@/lib/server/runtime"; +import { submitImageJob } from "@/lib/server/generation-service"; +import { assemblePrompt, type PromptAssemblyInput, type PromptMaterial } from "@/lib/prompt/assembler"; +import type { EnabledImageCapability } from "@/lib/types"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const jobs = (await listGenerationJobs()).filter((job) => job.capability !== "video.generate"); + return jsonOk({ jobs }); + } catch (error) { + return jsonError(error, 500); + } +} + +export async function POST(request: Request) { + try { + const body = await readJsonBody<{ + capability?: EnabledImageCapability; + prompt?: string; + imageUrls?: string[]; + materials?: PromptMaterial[]; + promptAssembly?: PromptAssemblyInput; + inputAssetIds?: string[]; + scale?: number; + width?: number; + height?: number; + min_ratio?: number; + max_ratio?: number; + force_single?: boolean; + }>(request); + const capability = body.capability || "image.generate"; + const assembled = body.promptAssembly + ? assemblePrompt({ ...body.promptAssembly, mode: "image", materials: body.materials || body.promptAssembly.materials || [] }) + : undefined; + const materialImages = (body.materials || assembled?.materials || []) + .filter((material) => material.type === "image") + .map((material) => material.url); + const job = await submitImageJob({ + capability, + prompt: body.prompt || assembled?.prompt, + imageUrls: body.imageUrls || materialImages, + inputAssetIds: body.inputAssetIds || (body.materials || []).map((material) => material.id).filter(Boolean) as string[], + scale: asNumber(body.scale), + width: asNumber(body.width), + height: asNumber(body.height), + min_ratio: asNumber(body.min_ratio), + max_ratio: asNumber(body.max_ratio), + force_single: Boolean(body.force_single) + }, requestOrigin(request)); + return jsonOk({ job: await getGenerationJob(job.id) }, { status: 202 }); + } catch (error) { + return jsonError(error); + } +} + +function asNumber(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/app/api/generations/video/[id]/route.ts b/app/api/generations/video/[id]/route.ts new file mode 100644 index 0000000..4809911 --- /dev/null +++ b/app/api/generations/video/[id]/route.ts @@ -0,0 +1,37 @@ +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 }> }) { + try { + const { id } = await context.params; + const job = await syncVideoJob(id, requestOrigin(request)); + return jsonOk({ job }); + } catch (error) { + return jsonError(error, 500); + } +} + +export async function DELETE(_request: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const job = await getGenerationJob(id); + if (!job || job.capability !== "video.generate") return jsonError("任务不存在", 404); + const deletedAssetIds: string[] = []; + for (const assetId of job.outputAssetIds) { + const asset = await getAsset(assetId); + if (!asset) continue; + await deleteStoredAsset(asset); + await deleteAsset(asset.id); + deletedAssetIds.push(asset.id); + } + await deleteGenerationJob(id); + return jsonOk({ ok: true, deletedJobId: id, deletedAssetIds }); + } catch (error) { + return jsonError(error, 500); + } +} diff --git a/app/api/generations/video/route.ts b/app/api/generations/video/route.ts new file mode 100644 index 0000000..647f006 --- /dev/null +++ b/app/api/generations/video/route.ts @@ -0,0 +1,25 @@ +import { listGenerationJobs } from "@/lib/server/data-store"; +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requestOrigin } from "@/lib/server/runtime"; +import { submitVideoJob, type SubmitVideoJobInput } from "@/lib/server/video-generation-service"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const jobs = (await listGenerationJobs()).filter((job) => job.capability === "video.generate"); + return jsonOk({ jobs }); + } catch (error) { + return jsonError(error, 500); + } +} + +export async function POST(request: Request) { + try { + const body = await readJsonBody>(request); + const job = await submitVideoJob(body, requestOrigin(request)); + return jsonOk({ job }, { status: 202 }); + } catch (error) { + return jsonError(error); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..fff87aa --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,38 @@ +import { jsonOk } from "@/lib/server/api"; +import { getEffectiveImageEngine, getEvolinkImageSettings, shouldMockEvolinkApi } from "@/lib/evolink/image-client"; +import { getVisibleImageCapabilities } from "@/lib/jimeng/capabilities"; +import { shouldMockVisualApi } from "@/lib/volcengine/visual-client"; +import { getSeedanceConfig, shouldMockSeedance } from "@/lib/seedance/client"; + +export const runtime = "nodejs"; + +export async function GET() { + const evolink = getEvolinkImageSettings(); + return jsonOk({ + ok: true, + appId: "zhinian-web-studio", + webOnly: true, + visualApiMode: shouldMockVisualApi() ? "mock" : "volcengine", + evolinkMode: shouldMockEvolinkApi() ? "mock" : "evolink", + seedanceMode: shouldMockSeedance() ? "mock" : "seedance", + capabilities: [ + ...getVisibleImageCapabilities().map((capability) => { + const engine = getEffectiveImageEngine(capability.id); + return { + id: capability.id, + label: capability.label, + engine, + engineLabel: engine === "evolink" ? "EvoLink" : "即梦", + reqKey: engine === "evolink" ? evolink.model : capability.reqKey + }; + }), + { + id: "video.generate", + label: "Seedance 视频生成", + engine: "seedance", + engineLabel: "Seedance", + reqKey: getSeedanceConfig().model + } + ] + }); +} diff --git a/app/api/prompt/assemble/route.ts b/app/api/prompt/assemble/route.ts new file mode 100644 index 0000000..8e330f2 --- /dev/null +++ b/app/api/prompt/assemble/route.ts @@ -0,0 +1,13 @@ +import { assemblePrompt, type PromptAssemblyInput } from "@/lib/prompt/assembler"; +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + const body = await readJsonBody>(request); + return jsonOk(assemblePrompt(body)); + } catch (error) { + return jsonError(error); + } +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..95ad579 --- /dev/null +++ b/app/api/settings/route.ts @@ -0,0 +1,21 @@ +import { getApiSettings, saveApiSettings } from "@/lib/server/app-settings"; +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + return jsonOk(await getApiSettings()); + } catch (error) { + return jsonError(error, 500); + } +} + +export async function POST(request: Request) { + try { + const body = await readJsonBody<{ values?: Record }>(request); + return jsonOk(await saveApiSettings(body.values || {})); + } catch (error) { + return jsonError(error, 500); + } +} diff --git a/app/assets/page.tsx b/app/assets/page.tsx new file mode 100644 index 0000000..709592c --- /dev/null +++ b/app/assets/page.tsx @@ -0,0 +1,7 @@ +import { AssetManager } from "@/components/asset-manager"; + +export const dynamic = "force-dynamic"; + +export default function AssetsPage() { + return ; +} diff --git a/app/create/page.tsx b/app/create/page.tsx new file mode 100644 index 0000000..d19e720 --- /dev/null +++ b/app/create/page.tsx @@ -0,0 +1,16 @@ +import { CreateStudio } from "@/components/create-studio"; + +export default async function CreatePage({ + searchParams +}: { + searchParams?: Promise>; +}) { + const params = await searchParams; + const modeParam = Array.isArray(params?.mode) ? params?.mode[0] : params?.mode; + const initialMode = modeParam === "video" || modeParam === "inpaint" || modeParam === "upscale" + ? modeParam + : modeParam === "edit" + ? "inpaint" + : "image"; + return ; +} diff --git a/app/generated-results/[...path]/route.ts b/app/generated-results/[...path]/route.ts new file mode 100644 index 0000000..b2156c5 --- /dev/null +++ b/app/generated-results/[...path]/route.ts @@ -0,0 +1,15 @@ +import { readLocalServedFile } 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 readLocalServedFile("generated-results", 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/globals.css b/app/globals.css new file mode 100644 index 0000000..bb3ec7d --- /dev/null +++ b/app/globals.css @@ -0,0 +1,2503 @@ +:root { + color-scheme: light; + --bg: #f6f7f4; + --panel: #ffffff; + --panel-muted: #f0f3ef; + --ink: #18201d; + --muted: #61706b; + --line: #dce3dd; + --green: #167a5b; + --green-dark: #0f5d46; + --coral: #c85d4a; + --amber: #a26f15; + --blue: #316f9d; + --shadow: 0 16px 40px rgba(31, 43, 37, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: Arial, "PingFang SC", "Microsoft YaHei", sans-serif; + letter-spacing: 0; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +.app-shell { + min-height: 100vh; + display: block; +} + +.topbar { + min-height: 70px; + display: grid; + grid-template-columns: minmax(150px, 1fr) auto minmax(150px, 1fr); + align-items: center; + gap: 18px; + padding: 12px 28px; + background: rgba(246, 247, 244, 0.94); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(12px); + position: sticky; + top: 0; + z-index: 20; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + justify-self: start; + min-width: 0; +} + +.brand-mark { + width: auto; + height: 30px; + display: flex; + align-items: center; + flex: 0 0 auto; +} + +.brand-logo-img { + display: block; + width: 104px; + max-height: 30px; + height: auto; + object-fit: contain; +} + +.brand-title { + font-weight: 800; + font-size: 17px; +} + +.brand-subtitle { + color: var(--muted); + font-size: 12px; + margin-top: 3px; +} + +.nav { + display: flex; + align-items: center; + gap: 6px; +} + +.top-nav { + justify-self: center; + padding: 4px; + border: 1px solid var(--line); + border-radius: 999px; + background: #edf2ee; +} + +.nav-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 34px; + padding: 0 13px; + border-radius: 999px; + color: var(--muted); + font-size: 13px; + font-weight: 800; + white-space: nowrap; +} + +.nav-link:hover, +.nav-link.active { + background: #ffffff; + color: var(--ink); + box-shadow: 0 1px 5px rgba(31, 43, 37, 0.08); +} + +.nav-link svg { + width: 15px; + height: 15px; +} + +.main { + min-width: 0; + width: min(1240px, calc(100vw - 32px)); + margin: 0 auto; + padding: 18px 0 32px; +} + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px; + margin-bottom: 24px; +} + +.eyebrow { + color: var(--green); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 8px; + font-size: 34px; + line-height: 1.15; +} + +h2 { + font-size: 22px; + margin-bottom: 14px; +} + +h3 { + font-size: 16px; + margin-bottom: 8px; +} + +.muted { + color: var(--muted); +} + +.grid { + display: grid; + gap: 16px; +} + +.grid.cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid.cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.grid.cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.panel, +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.panel { + padding: 20px; +} + +.card { + overflow: hidden; +} + +.card-body { + padding: 16px; +} + +.metric { + display: grid; + gap: 10px; +} + +.metric-value { + font-size: 28px; + font-weight: 800; +} + +.toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.button, +.icon-button { + border: 1px solid var(--line); + background: #ffffff; + color: var(--ink); + border-radius: 8px; + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 14px; + font-weight: 700; + white-space: nowrap; +} + +.button.primary { + background: var(--green); + border-color: var(--green); + color: #ffffff; +} + +.button.danger { + color: #8d2f21; + border-color: #e6b8ae; +} + +.icon-button { + width: 40px; + padding: 0; +} + +.button:disabled, +.icon-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.field { + display: grid; + gap: 7px; + margin-bottom: 14px; +} + +.field label { + font-weight: 700; + font-size: 13px; +} + +.field input, +.field textarea, +.field select { + width: 100%; + border: 1px solid var(--line); + background: #ffffff; + color: var(--ink); + border-radius: 8px; + min-height: 42px; + padding: 10px 12px; +} + +.field textarea { + resize: vertical; + min-height: 108px; +} + +.segmented { + display: flex; + gap: 4px; + padding: 4px; + background: var(--panel-muted); + border-radius: 8px; + border: 1px solid var(--line); +} + +.segmented button { + flex: 1; + border: 0; + border-radius: 6px; + min-height: 38px; + background: transparent; + font-weight: 700; + color: var(--muted); +} + +.segmented button.active { + background: #ffffff; + color: var(--ink); + box-shadow: 0 1px 5px rgba(31, 43, 37, 0.08); +} + +.asset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); + gap: 14px; +} + +.asset-thumb { + width: 100%; + aspect-ratio: 4 / 3; + background: var(--panel-muted); + object-fit: cover; + display: block; +} + +.asset-placeholder { + width: 100%; + aspect-ratio: 4 / 3; + display: grid; + place-items: center; + background: var(--panel-muted); + color: var(--muted); +} + +.status { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 4px 9px; + background: #edf3f0; + color: var(--green-dark); + font-size: 12px; + font-weight: 800; +} + +.status.failed, +.status.expired, +.status.cancelled { + background: #fae9e5; + color: #8d2f21; +} + +.status.running, +.status.queued { + background: #f6eddc; + color: #7c530d; +} + +.callout { + padding: 14px 16px; + background: #f7faf7; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--ink); +} + +.callout[role="alert"], +.asset-error { + background: #fff4f1; + border-color: #e6b8ae; + color: #8d2f21; +} + +.create-studio { + display: grid; + gap: 16px; + align-items: start; + max-width: 980px; + margin-inline: auto; +} + +.create-studio.enhance-studio { + max-width: 1240px; +} + +.create-mode-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.mode-switch { + width: min(560px, 100%); +} + +.mode-switch button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; +} + +.create-actions { + margin-left: auto; +} + +.prompt-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.prompt-upload-button input { + display: none; +} + +.prompt-upload-button:hover { + border-color: #b8c7c1; + background: #f7faf7; +} + +.prompt-field textarea { + min-height: 260px; + line-height: 1.6; + color: var(--ink); +} + +.prompt-field textarea::placeholder { + color: #9aa8a1; +} + +.prompt-editor-wrap { + position: relative; +} + +.prompt-editor-wrap textarea { + position: relative; + z-index: 2; + background: transparent; +} + +.prompt-token-layer { + position: absolute; + inset: 0; + z-index: 1; + overflow: hidden; + border: 1px solid transparent; + border-radius: 8px; + color: transparent; + font: inherit; + line-height: 1.6; + padding: 10px 12px; + pointer-events: none; + white-space: pre-wrap; + word-break: break-word; +} + +.prompt-token-card { + display: inline; + border-radius: 5px; + background: rgba(57, 217, 119, 0.34); + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + box-shadow: 0 0 0 3px rgba(57, 217, 119, 0.34); +} + +.prompt-token-card.unbound { + background: rgba(255, 183, 38, 0.42); + box-shadow: 0 0 0 3px rgba(255, 183, 38, 0.42); +} + +.mention-popover { + position: absolute; + left: 10px; + right: 10px; + bottom: 10px; + z-index: 10; + display: grid; + gap: 4px; + max-height: 220px; + overflow: auto; + padding: 6px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + box-shadow: 0 12px 28px rgba(31, 43, 37, 0.14); +} + +.mention-option { + min-height: 34px; + border: 0; + border-radius: 6px; + background: transparent; + display: grid; + grid-template-columns: 28px auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 0 8px; + text-align: left; + font-size: 13px; +} + +.mention-option.active, +.mention-option:hover { + background: #f0f3ef; +} + +.mention-kind { + color: var(--muted); + font-size: 12px; +} + +.upload-icon-button { + border: 1px solid var(--line); + background: #ffffff; + color: var(--ink); + border-radius: 8px; + min-width: 108px; + height: 38px; + padding: 0 10px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 13px; + font-weight: 800; + white-space: nowrap; + cursor: pointer; +} + +.upload-icon-button input { + display: none; +} + +.upload-icon-button:hover, +.material-card:hover { + border-color: #b8c7c1; + background: #f7faf7; +} + +.material-card.referenced { + border-color: #b7d6c8; + background: #f2faf6; +} + +.material-card.missing { + border-color: var(--line); + background: #ffffff; +} + +.material-board { + min-height: 42px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 8px; +} + +.prompt-material-board { + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + margin-top: 10px; +} + +.material-card { + position: relative; + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--ink); + overflow: hidden; +} + +.material-card-main { + width: 100%; + min-height: 68px; + border: 0; + background: transparent; + color: inherit; + padding: 8px 34px 8px 8px; + display: flex; + align-items: center; + gap: 10px; + text-align: left; +} + +.prompt-material-board .material-card-main { + min-height: 58px; +} + +.material-card-copy { + min-width: 0; + display: grid; + gap: 5px; +} + +.material-card-head { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.material-state { + display: inline-flex; + align-items: center; + min-height: 21px; + padding: 0 7px; + border-radius: 999px; + background: #edf3f0; + color: var(--muted); + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} + +.material-card.referenced .material-state { + background: #dff2e8; + color: var(--green-dark); +} + +.material-remove { + position: absolute; + top: 6px; + right: 6px; + width: 24px; + height: 24px; + border: 0; + border-radius: 6px; + background: rgba(255, 255, 255, 0.86); + color: var(--muted); + display: grid; + place-items: center; + padding: 0; +} + +.material-remove:hover { + background: #fae9e5; + color: #8d2f21; +} + +.material-preview { + width: 26px; + height: 26px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-muted); + display: inline-grid; + place-items: center; + overflow: hidden; + color: var(--muted); + flex: 0 0 auto; +} + +.material-preview.tiny { + width: 24px; + height: 24px; +} + +.material-preview.large { + width: 50px; + height: 50px; +} + +.prompt-material-board .material-preview.large { + width: 42px; + height: 42px; +} + +.material-preview img, +.material-preview video { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.chip-token { + color: var(--green-dark); + white-space: nowrap; +} + +.chip-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compact-hint { + font-size: 13px; + line-height: 1.5; +} + +.mini-button { + min-height: 32px; + padding: 0 10px; + font-size: 12px; +} + +.inline-settings { + display: grid; + gap: 10px; + align-items: end; +} + +.inline-settings { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 12px; +} + +.inline-field { + margin-bottom: 0; +} + +.range-field input { + padding-inline: 0; +} + +.toggle-line { + min-height: 42px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + font-weight: 800; +} + +.studio-messages { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.success-callout { + background: #eef8f2; + border-color: #b7d6c8; + color: var(--green-dark); +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 12px; +} + +.pager-meta { + min-width: 58px; + color: var(--muted); + font-size: 13px; + font-weight: 800; + text-align: center; +} + +.compact-job .card-body { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.compact-job h3 { + max-width: 42ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asset-manager-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; +} + +.asset-view-switch { + width: min(240px, 100%); +} + +.asset-error { + margin-bottom: 14px; +} + +.asset-history-grid { + align-items: stretch; +} + +.asset-history-card { + display: grid; + grid-template-columns: minmax(112px, 34%) minmax(0, 1fr); + min-height: 0; + min-width: 0; +} + +.asset-preview-button { + width: 100%; + height: 140px; + display: block; + border: 0; + padding: 0; + overflow: hidden; + background: transparent; + color: inherit; + cursor: zoom-in; + text-align: left; +} + +.asset-history-card .asset-thumb, +.asset-history-card .asset-placeholder { + width: 100%; + height: 100%; + aspect-ratio: auto; + object-fit: cover; +} + +.asset-history-body { + min-width: 0; + display: grid; + grid-template-rows: auto auto 1fr; + gap: 8px; + padding: 12px; +} + +.asset-history-body p { + margin-bottom: 0; +} + +.asset-card-title { + min-width: 0; + width: 100%; + justify-content: space-between; + flex-wrap: nowrap; +} + +.asset-card-title h3 { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + margin-bottom: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asset-card-title .status { + flex: 0 0 auto; +} + +.asset-card-actions { + align-self: end; + justify-content: flex-start; + align-items: center; + gap: 6px; + min-height: 32px; + margin-top: 4px; +} + +.asset-card-actions .compact-hint { + display: none; +} + +.asset-card-actions .mini-button { + min-height: 30px; + padding: 0 8px; +} + +.asset-kind-placeholder { + gap: 8px; + color: var(--muted); +} + +.task-history-list { + gap: 10px; +} + +.job-summary { + min-width: 0; +} + +.job-summary p { + margin-bottom: 4px; +} + +.job-actions { + flex: 0 0 auto; + justify-content: flex-end; +} + +.job-detail-panel { + display: grid; + gap: 14px; + padding: 0 16px 16px; + border-top: 1px solid var(--line); +} + +.job-detail-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 0; + padding-top: 14px; +} + +.job-detail-grid div { + min-width: 0; + display: grid; + gap: 4px; +} + +.job-detail-grid dt { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.job-detail-grid dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + font-size: 13px; +} + +.job-prompt-detail { + display: grid; + gap: 5px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfcfa; +} + +.job-prompt-detail p { + margin: 0; + color: var(--ink); + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; +} + +.asset-preview-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 16px; + background: rgba(24, 32, 29, 0.45); +} + +.asset-preview-dialog { + width: min(920px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + display: grid; + grid-template-rows: auto minmax(0, auto); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: 0 24px 70px rgba(24, 32, 29, 0.22); +} + +.asset-preview-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border-bottom: 1px solid var(--line); +} + +.asset-preview-head > div { + min-width: 0; + display: grid; + gap: 2px; +} + +.asset-preview-head strong, +.asset-preview-head span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asset-preview-media { + min-height: 0; + max-height: calc(100dvh - 118px); + display: grid; + place-items: center; + overflow: hidden; + padding: 12px; + background: #f8faf7; +} + +.asset-preview-media img, +.asset-preview-media video { + width: auto; + height: auto; + max-width: 100%; + max-height: calc(100dvh - 150px); + border-radius: 6px; + object-fit: contain; + background: #ffffff; +} + +.asset-preview-media audio { + width: min(520px, 100%); +} + +.settings-panel { + display: grid; + gap: 14px; +} + +.settings-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.settings-tabs { + width: min(420px, 100%); +} + +.settings-actions h2, +.settings-group h2 { + margin-bottom: 4px; +} + +.settings-group { + display: grid; + gap: 14px; +} + +.settings-field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.settings-field { + margin-bottom: 0; +} + +.settings-field > span { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-weight: 800; + font-size: 13px; +} + +.settings-field em { + color: var(--muted); + font-size: 11px; + font-style: normal; +} + +.settings-field small { + color: var(--muted); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-capability-list { + display: grid; + gap: 10px; +} + +.settings-service-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.settings-service-badge { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8faf8; + padding: 10px 12px; + display: grid; + gap: 4px; +} + +.settings-service-badge.ready { + border-color: #b7d6c8; + background: #f2faf6; +} + +.settings-service-badge span { + color: var(--muted); + font-size: 12px; +} + +.settings-service-badge strong { + font-size: 15px; +} + +.settings-engine-table { + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: #ffffff; +} + +.settings-engine-row { + display: grid; + grid-template-columns: minmax(150px, 1.15fr) minmax(180px, 0.95fr) minmax(120px, 0.7fr) minmax(160px, 1.2fr); + align-items: center; + gap: 12px; + padding: 12px; + border-top: 1px solid var(--line); +} + +.settings-engine-row:first-child { + border-top: 0; +} + +.settings-engine-head { + color: var(--muted); + background: #f7f9f7; + font-size: 12px; + font-weight: 800; +} + +.settings-engine-name { + min-width: 0; + display: grid; + gap: 3px; +} + +.settings-engine-name strong, +.settings-engine-value { + font-size: 14px; +} + +.settings-engine-name small, +.settings-engine-key small { + color: var(--muted); + font-size: 11px; + overflow-wrap: anywhere; +} + +.settings-engine-control { + width: 100%; +} + +.settings-loading { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.image-upload-button { + flex: 0 0 auto; +} + +.editor-picker-panel { + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfcfa; + padding: 10px; + margin-bottom: 14px; +} + +.editor-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + gap: 10px; + margin-bottom: 14px; + max-height: 330px; + overflow: auto; + padding: 2px; +} + +.editor-thumb-option { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--ink); + overflow: hidden; + padding: 0; + text-align: left; +} + +.editor-thumb-option.selected { + border-color: var(--green); + box-shadow: 0 0 0 2px rgba(22, 122, 91, 0.14); +} + +.editor-thumb-option img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; + background: var(--panel-muted); +} + +.editor-thumb-info { + min-width: 0; + display: grid; + gap: 3px; + padding: 8px; +} + +.editor-thumb-info strong, +.editor-thumb-info span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.editor-thumb-info strong { + font-size: 12px; +} + +.editor-thumb-info span { + color: var(--muted); + font-size: 11px; +} + +.editor-empty { + grid-column: 1 / -1; +} + +.selected-asset-line { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfcfa; + padding: 8px; + margin-bottom: 14px; +} + +.editor-selection-summary { + flex-wrap: wrap; +} + +.editor-selection-summary div { + flex: 1 1 180px; +} + +.selected-asset-line img { + width: 44px; + height: 44px; + border-radius: 6px; + object-fit: cover; + background: var(--panel-muted); +} + +.selected-asset-placeholder { + width: 44px; + height: 44px; + border-radius: 6px; + display: grid; + place-items: center; + background: var(--panel-muted); + color: var(--muted); + flex: 0 0 auto; +} + +.selected-asset-line div { + min-width: 0; + display: grid; + gap: 3px; +} + +.selected-asset-line strong, +.selected-asset-line span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.editor-canvas-wrap { + position: relative; + width: 100%; + max-width: 760px; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: #ffffff; +} + +.editor-canvas-wrap img, +.editor-canvas-wrap canvas { + display: block; + width: 100%; + height: auto; +} + +.editor-canvas-wrap canvas { + position: absolute; + inset: 0; + cursor: crosshair; + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.5)); + opacity: 0.82; + touch-action: none; +} + +@media (max-width: 920px) { + .topbar { + grid-template-columns: 1fr; + justify-items: center; + gap: 10px; + padding: 12px 16px; + } + + .brand { + justify-self: center; + } + + .top-nav { + width: min(100%, 560px); + justify-content: center; + } + + .nav-link { + justify-content: center; + font-size: 13px; + } + + .main { + width: min(980px, calc(100% - 32px)); + padding: 16px 0 28px; + } + + .grid.cols-2, + .grid.cols-3, + .grid.cols-4, + .settings-service-strip, + .settings-engine-row, + .settings-field-grid { + grid-template-columns: 1fr; + } + + .create-studio, + .inline-settings { + grid-template-columns: 1fr; + } + + .create-mode-bar { + align-items: stretch; + flex-direction: column; + } + + .settings-actions { + align-items: stretch; + flex-direction: column; + } + + .settings-tabs, + .settings-actions .toolbar, + .settings-actions .button { + width: 100%; + } + + .settings-engine-head { + display: none; + } + + .mode-switch, + .create-actions { + width: 100%; + } + + .create-actions .button { + flex: 1; + } +} + +@media (max-width: 560px) { + h1 { + font-size: 28px; + } + + .topbar { + padding: 10px 12px; + } + + .brand-title { + font-size: 15px; + } + + .brand-subtitle { + display: none; + } + + .page-head { + align-items: flex-start; + flex-direction: column; + } + + .top-nav { + justify-content: center; + overflow: hidden; + } + + .nav-link { + flex: 0 0 auto; + gap: 0; + padding: 0 10px; + } + + .nav-link svg { + display: none; + } + + .main { + width: calc(100% - 24px); + padding-top: 12px; + } + + .asset-history-card { + grid-template-columns: 104px minmax(0, 1fr); + min-height: 0; + } + + .asset-preview-button { + height: 140px; + } + + .prompt-field textarea { + min-height: 220px; + } + + .upload-icon-button { + min-width: 108px; + } + + .material-board { + grid-template-columns: 1fr; + } + + .compact-job .card-body { + align-items: flex-start; + flex-direction: column; + } + + .job-actions { + width: 100%; + justify-content: flex-start; + } + + .job-detail-grid { + grid-template-columns: 1fr; + } + + .asset-manager-toolbar, + .editor-selection-summary { + align-items: stretch; + flex-direction: column; + } + + .editor-selection-summary div { + flex: 0 1 auto; + width: 100%; + } + + .editor-selection-summary > img, + .editor-selection-summary .selected-asset-placeholder { + align-self: flex-start; + } + +} + +/* UI/UX Pro Max pass: professional creator workspace tokens and interaction layer. */ +:root { + color-scheme: light; + --bg: #f4f7f8; + --surface: #ffffff; + --surface-raised: #ffffff; + --surface-subtle: #eef4f4; + --panel: var(--surface); + --panel-muted: var(--surface-subtle); + --ink: #111827; + --muted: #5d6b78; + --muted-strong: #334155; + --line: #d7e0e4; + --line-strong: #b9c8cf; + --green: #0f766e; + --green-dark: #0b5f59; + --coral: #c24137; + --amber: #a16207; + --blue: #2563eb; + --indigo: #4f46e5; + --success: #0f766e; + --warning: #a16207; + --danger: #b42318; + --focus: #2563eb; + --shadow: 0 14px 34px rgba(15, 23, 42, 0.08); + --shadow-soft: 0 1px 2px rgba(15, 23, 42, 0.08), 0 10px 28px rgba(15, 23, 42, 0.06); + --shadow-strong: 0 24px 70px rgba(15, 23, 42, 0.22); + --radius: 8px; + --radius-sm: 6px; + --tap: 44px; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in: cubic-bezier(0.7, 0, 0.84, 0); + --duration-fast: 150ms; + --duration: 220ms; +} + +html { + min-width: 320px; + scroll-behavior: smooth; +} + +body { + min-width: 320px; + background: + linear-gradient(180deg, rgba(232, 241, 243, 0.74), rgba(244, 247, 248, 0) 340px), + var(--bg); + color: var(--ink); + font-family: Inter, Arial, "PingFang SC", "Microsoft YaHei", sans-serif; + line-height: 1.5; + text-rendering: optimizeLegibility; +} + +button, +input, +textarea, +select { + min-width: 0; +} + +button, +a, +input, +textarea, +select { + touch-action: manipulation; +} + +button, +.button, +.icon-button, +.nav-link, +.asset-preview-button, +.material-card-main, +.editor-thumb-option { + transition: + background-color var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); +} + +button:hover:not(:disabled), +.button:hover:not(:disabled), +.icon-button:hover:not(:disabled), +.nav-link:hover, +.asset-preview-button:hover, +.material-card:hover, +.editor-thumb-option:hover { + transform: translateY(-1px); +} + +button:active:not(:disabled), +.button:active:not(:disabled), +.icon-button:active:not(:disabled), +.nav-link:active { + transform: translateY(0) scale(0.99); +} + +:focus-visible { + outline: 3px solid rgba(37, 99, 235, 0.28); + outline-offset: 3px; +} + +.skip-link { + position: fixed; + left: 14px; + top: 10px; + z-index: 100; + min-height: var(--tap); + display: inline-flex; + align-items: center; + border: 1px solid var(--line-strong); + border-radius: var(--radius); + background: var(--surface); + color: var(--ink); + padding: 0 14px; + box-shadow: var(--shadow-soft); + font-weight: 800; + transform: translateY(-72px); +} + +.skip-link:focus { + transform: translateY(0); +} + +.app-shell { + min-height: 100dvh; +} + +.topbar { + min-height: 76px; + grid-template-columns: minmax(220px, 1fr) auto minmax(220px, 1fr); + padding: 14px 28px; + background: rgba(249, 251, 251, 0.88); + border-bottom-color: rgba(185, 200, 207, 0.7); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.65) inset; + backdrop-filter: blur(18px) saturate(1.15); +} + +.brand { + border-radius: var(--radius); + padding: 4px 6px 4px 4px; +} + +.brand:hover { + background: rgba(255, 255, 255, 0.62); +} + +.brand-mark { + border: 0; + background: transparent; + color: inherit; + box-shadow: none; +} + +.brand-title { + font-size: 16px; + letter-spacing: 0; +} + +.brand-subtitle { + color: var(--muted); +} + +.top-nav { + min-height: 48px; + background: #e8eef1; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78); +} + +.nav-link { + min-height: var(--tap); + padding: 0 15px; + color: var(--muted-strong); +} + +.nav-link:hover, +.nav-link.active { + background: var(--surface-raised); + color: var(--ink); + box-shadow: var(--shadow-soft); +} + +.nav-link.active { + border: 1px solid rgba(185, 200, 207, 0.82); +} + +.main { + width: min(1280px, calc(100vw - 32px)); + padding: 24px 0 40px; +} + +.workspace-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 18px; + margin-bottom: 18px; +} + +.workspace-kicker, +.eyebrow { + color: var(--green-dark); + font-size: 12px; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.workspace-title { + margin: 5px 0 6px; + font-size: clamp(28px, 4vw, 42px); + line-height: 1.12; +} + +.workspace-copy { + max-width: 72ch; + margin: 0; + color: var(--muted); + font-size: 15px; +} + +.workspace-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.meta-pill { + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.76); + color: var(--muted-strong); + padding: 0 11px; + font-size: 12px; + font-weight: 800; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.72) inset; +} + +.panel, +.card { + border-color: rgba(215, 224, 228, 0.96); + border-radius: var(--radius); + box-shadow: var(--shadow-soft); +} + +.panel { + padding: 22px; +} + +.panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; +} + +.panel-head h2, +.panel-head h3, +.panel-head p { + margin-bottom: 0; +} + +.card { + background: var(--surface); +} + +.button, +.icon-button, +.upload-icon-button { + min-height: var(--tap); + border-color: var(--line); + border-radius: var(--radius); + background: var(--surface); + color: var(--ink); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.75) inset; +} + +.button:hover:not(:disabled), +.icon-button:hover:not(:disabled), +.upload-icon-button:hover { + border-color: var(--line-strong); + background: #f8fbfb; + box-shadow: var(--shadow-soft); +} + +.button.primary { + border-color: var(--green); + background: var(--green); + color: #ffffff; + box-shadow: 0 10px 22px rgba(15, 118, 110, 0.2); +} + +.button.primary:hover:not(:disabled) { + border-color: var(--green-dark); + background: var(--green-dark); +} + +.button.danger { + color: var(--danger); + border-color: #efc7c1; + background: #fff9f8; +} + +.button.danger:hover:not(:disabled) { + background: #fff1ee; +} + +.icon-button { + width: var(--tap); + height: var(--tap); +} + +.mini-button { + min-height: var(--tap); +} + +.field { + gap: 8px; +} + +.field label, +.prompt-label-row label, +.settings-field > span { + color: var(--muted-strong); + font-weight: 850; +} + +.field input, +.field textarea, +.field select, +.settings-engine-control { + min-height: var(--tap); + border-color: var(--line); + border-radius: var(--radius); + background: #fbfdfd; + transition: + border-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out), + background-color var(--duration-fast) var(--ease-out); +} + +.field input:focus, +.field textarea:focus, +.field select:focus, +.settings-engine-control:focus { + border-color: rgba(37, 99, 235, 0.7); + background: #ffffff; + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12); + outline: 0; +} + +.segmented { + min-height: 48px; + border-color: rgba(185, 200, 207, 0.82); + border-radius: var(--radius); + background: #e8eef1; +} + +.segmented button { + min-height: var(--tap); + color: var(--muted-strong); +} + +.segmented button.active { + background: #ffffff; + color: var(--ink); + box-shadow: var(--shadow-soft); +} + +.status { + min-height: 25px; + background: #e8f5f1; + color: var(--green-dark); +} + +.status.failed, +.status.expired, +.status.cancelled { + background: #fff0ed; + color: var(--danger); +} + +.status.running, +.status.queued { + background: #fff6dc; + color: var(--warning); +} + +.callout { + border-color: var(--line); + background: #f8fbfb; +} + +.callout[role="alert"], +.asset-error { + border-color: #efc7c1; + background: #fff5f3; + color: var(--danger); +} + +.success-callout { + border-color: #b9dccc; + background: #eefaf4; + color: var(--green-dark); +} + +.create-studio { + max-width: 1060px; +} + +.create-studio.enhance-studio { + max-width: 1280px; +} + +.create-mode-bar { + position: sticky; + top: 92px; + z-index: 12; + padding: 10px; + border: 1px solid rgba(215, 224, 228, 0.82); + border-radius: var(--radius); + background: rgba(249, 251, 251, 0.86); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(16px) saturate(1.08); +} + +.mode-switch { + width: min(680px, 100%); + min-width: 0; +} + +.prompt-field textarea { + min-height: 300px; + background: transparent; + font-size: 15px; +} + +.prompt-editor-wrap { + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fbfdfd; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82); + transition: + border-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out); +} + +.prompt-editor-wrap:focus-within { + border-color: rgba(37, 99, 235, 0.7); + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12); +} + +.prompt-editor-wrap textarea { + border: 0; + box-shadow: none; +} + +.prompt-token-layer { + border: 0; +} + +.prompt-token-card { + background: rgba(45, 212, 191, 0.28); + box-shadow: 0 0 0 3px rgba(45, 212, 191, 0.28); +} + +.prompt-token-card.unbound { + background: rgba(251, 191, 36, 0.34); + box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.34); +} + +.mention-popover { + border-color: var(--line-strong); + box-shadow: var(--shadow-strong); +} + +.mention-option { + min-height: 40px; +} + +.mention-option.active, +.mention-option:hover { + background: #eef4f4; +} + +.material-board { + gap: 10px; +} + +.material-card { + border-color: var(--line); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.74) inset; +} + +.material-card:hover { + border-color: var(--line-strong); + box-shadow: var(--shadow-soft); +} + +.material-remove { + width: 40px; + height: 40px; +} + +.material-card.referenced { + border-color: #a8d9cf; + background: #f1fbf8; +} + +.material-preview { + border-color: var(--line); + background: #e9f0f2; +} + +.inline-settings { + grid-template-columns: minmax(160px, 0.7fr) minmax(220px, 1fr) minmax(120px, 0.45fr); + align-items: end; + margin-top: 16px; + margin-bottom: 0; + padding-top: 16px; + border-top: 1px solid var(--line); +} + +.toggle-line { + min-height: var(--tap); + background: #fbfdfd; +} + +.asset-manager-toolbar, +.settings-actions { + padding: 10px; + border: 1px solid rgba(215, 224, 228, 0.82); + border-radius: var(--radius); + background: #f8fbfb; +} + +.asset-history-card { + border-color: rgba(215, 224, 228, 0.96); +} + +.asset-preview-button { + background: #e9f0f2; +} + +.asset-preview-button:hover { + filter: saturate(1.04); +} + +.asset-history-body { + gap: 10px; +} + +.asset-card-meta { + overflow-wrap: anywhere; +} + +.asset-card-actions .mini-button { + min-width: var(--tap); + min-height: var(--tap); + padding: 0 10px; +} + +.compact-job { + overflow: hidden; +} + +.compact-job .card-body { + min-height: 84px; +} + +.job-detail-panel { + background: #fbfdfd; +} + +.job-detail-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.asset-preview-backdrop { + background: rgba(15, 23, 42, 0.52); + backdrop-filter: blur(8px); +} + +.asset-preview-dialog { + border-color: rgba(215, 224, 228, 0.9); + border-radius: var(--radius); + box-shadow: var(--shadow-strong); +} + +.asset-preview-head { + background: #fbfdfd; +} + +.asset-preview-media { + background: #eef4f4; +} + +.settings-panel { + gap: 16px; +} + +.settings-service-badge { + background: #f8fbfb; +} + +.settings-service-badge.ready { + border-color: #a8d9cf; + background: #f1fbf8; +} + +.settings-engine-table { + border-color: var(--line); +} + +.settings-engine-row { + grid-template-columns: minmax(150px, 1.1fr) minmax(180px, 0.95fr) minmax(110px, 0.6fr) minmax(160px, 1fr); +} + +.settings-engine-row:not(.settings-engine-head):hover { + background: #fbfdfd; +} + +.editor-picker-panel, +.selected-asset-line, +.job-prompt-detail { + border-color: var(--line); + background: #f8fbfb; +} + +.editor-thumb-option { + border-color: var(--line); +} + +.editor-thumb-option.selected { + border-color: var(--green); + box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.16); +} + +.editor-canvas-wrap { + border-color: var(--line); + background: #ffffff; + box-shadow: var(--shadow-soft); +} + +.pagination .icon-button { + width: var(--tap); + height: var(--tap); + min-height: var(--tap); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + +@media (max-width: 920px) { + .topbar { + grid-template-columns: 1fr; + min-height: auto; + } + + .workspace-head { + grid-template-columns: 1fr; + } + + .workspace-meta { + justify-content: flex-start; + } + + .create-mode-bar { + position: static; + } + + .inline-settings { + grid-template-columns: 1fr; + } + + .job-detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .settings-engine-row { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + padding: 14px; + } + + .settings-engine-row:not(.settings-engine-head) { + border-top: 1px solid var(--line); + } +} + +@media (max-width: 560px) { + body { + background: var(--bg); + } + + .main { + width: calc(100% - 24px); + padding-bottom: 28px; + } + + .workspace-title { + font-size: 28px; + } + + .workspace-copy { + font-size: 14px; + } + + .panel { + padding: 16px; + } + + .top-nav { + width: 100%; + overflow-x: auto; + justify-content: flex-start; + scrollbar-width: none; + } + + .top-nav::-webkit-scrollbar { + display: none; + } + + .nav-link { + min-width: 88px; + gap: 7px; + padding: 0 12px; + } + + .nav-link svg { + display: block; + } + + .create-mode-bar, + .asset-manager-toolbar, + .settings-actions { + padding: 8px; + } + + .segmented { + max-width: 100%; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + } + + .segmented::-webkit-scrollbar { + display: none; + } + + .segmented button { + min-width: max-content; + padding: 0 12px; + } + + .create-mode-bar, + .mode-switch { + min-width: 0; + max-width: 100%; + overflow: hidden; + } + + .mode-switch.segmented { + overflow-x: auto; + } + + .prompt-field textarea { + min-height: 240px; + } + + .asset-history-card { + grid-template-columns: 1fr; + } + + .asset-preview-button { + height: auto; + aspect-ratio: 4 / 3; + } + + .job-detail-grid { + grid-template-columns: 1fr; + } + + .settings-service-strip { + grid-template-columns: 1fr; + } +} + +/* Compact header pass: keep the shell shorter and avoid horizontal scrollers below it. */ +.topbar { + min-height: 64px; + padding-block: 9px; +} + +.brand-mark { + width: auto; + height: 28px; +} + +.brand-title { + font-size: 15px; +} + +.top-nav { + min-height: 42px; +} + +.nav-link { + min-height: 38px; +} + +@media (max-width: 920px) { + .topbar { + grid-template-columns: auto minmax(0, 1fr); + justify-items: stretch; + gap: 12px; + padding: 8px 16px; + } + + .brand { + justify-self: start; + } + + .top-nav { + justify-self: end; + width: min(320px, 100%); + min-width: 0; + justify-content: flex-end; + overflow: visible; + } +} + +@media (max-width: 560px) { + .topbar { + grid-template-columns: auto minmax(0, 1fr); + padding: 7px 10px; + gap: 8px; + } + + .brand { + gap: 8px; + padding: 2px; + } + + .brand-mark { + width: auto; + height: 24px; + } + + .brand-logo-img { + width: 86px; + max-height: 24px; + } + + .brand-title { + display: none; + } + + .top-nav { + width: min(214px, 100%); + min-height: 38px; + padding: 3px; + overflow: visible; + } + + .nav-link { + flex: 1 1 0; + min-width: 0; + min-height: 34px; + gap: 4px; + padding: 0 6px; + font-size: 12px; + } + + .nav-link svg { + width: 14px; + height: 14px; + } + + .segmented, + .mode-switch.segmented { + overflow: visible; + } + + .mode-switch.segmented { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .mode-switch.segmented button { + min-width: 0; + min-height: 38px; + gap: 3px; + padding: 0 4px; + font-size: 12px; + } + + .mode-switch.segmented button svg { + width: 13px; + height: 13px; + } + + .asset-view-switch, + .settings-tabs { + display: grid; + } + + .asset-view-switch { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .settings-tabs { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .asset-view-switch button, + .settings-tabs button { + min-width: 0; + min-height: 38px; + padding: 0 4px; + font-size: 12px; + } +} + +@media (max-width: 560px) { + .workspace-head { + gap: 10px; + margin-bottom: 12px; + } + + .workspace-title { + margin-bottom: 4px; + } + + .workspace-copy { + line-height: 1.45; + } + + .create-mode-bar { + gap: 8px; + display: grid; + grid-template-columns: minmax(0, 1fr) var(--tap); + align-items: center; + } + + .create-actions .button { + width: var(--tap); + min-height: var(--tap); + padding: 0; + } + + .create-submit-label, + .mode-switch.segmented button svg { + display: none; + } + + .prompt-field textarea { + min-height: 180px; + } + + .inline-settings { + gap: 8px; + margin-top: 12px; + padding-top: 12px; + } + + .field { + margin-bottom: 10px; + } +} diff --git a/app/image-edit/page.tsx b/app/image-edit/page.tsx new file mode 100644 index 0000000..7c87560 --- /dev/null +++ b/app/image-edit/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function ImageEditPage() { + redirect("/create?mode=inpaint"); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..cd2e9ef --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { AppShell } from "@/components/app-shell"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "智念AIGC平台", + description: "智念AIGC平台:统一创作图片、视频、局部重绘与智能超清。" +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..28b5130 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function HomePage() { + redirect("/create"); +} diff --git a/app/planning-cases/[...path]/route.ts b/app/planning-cases/[...path]/route.ts new file mode 100644 index 0000000..c19b4b7 --- /dev/null +++ b/app/planning-cases/[...path]/route.ts @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..a18fbcf --- /dev/null +++ b/app/seedance-starter-assets/[...path]/route.ts @@ -0,0 +1,15 @@ +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/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..1ce6f68 --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,7 @@ +import { SettingsPanel } from "@/components/settings-panel"; + +export const dynamic = "force-dynamic"; + +export default function SettingsPage() { + return ; +} diff --git a/app/starter/[...path]/route.ts b/app/starter/[...path]/route.ts new file mode 100644 index 0000000..c6734cc --- /dev/null +++ b/app/starter/[...path]/route.ts @@ -0,0 +1,15 @@ +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/app/uploads/[...path]/route.ts b/app/uploads/[...path]/route.ts new file mode 100644 index 0000000..18327ec --- /dev/null +++ b/app/uploads/[...path]/route.ts @@ -0,0 +1,15 @@ +import { readLocalServedFile } 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 readLocalServedFile("uploads", 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/app-shell.tsx b/components/app-shell.tsx new file mode 100644 index 0000000..3e773a6 --- /dev/null +++ b/components/app-shell.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Archive, + Settings, + Sparkles +} from "lucide-react"; +import clsx from "clsx"; +import { revealChildren, runScopedMotion } from "@/lib/ui/motion"; + +const nav = [ + { href: "/create", label: "创作", icon: Sparkles }, + { href: "/assets", label: "结果", icon: Archive }, + { href: "/settings", label: "设置", icon: Settings } +]; + +export function AppShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const shellRef = useRef(null); + + useEffect(() => { + return runScopedMotion(shellRef, (scope) => revealChildren(scope, "[data-shell-animate]")); + }, []); + + return ( +
+ 跳到主要内容 +
+ + +
+
智念AIGC平台
+
+ + +
+
{children}
+
+ ); +} diff --git a/components/asset-manager.tsx b/components/asset-manager.tsx new file mode 100644 index 0000000..1a71198 --- /dev/null +++ b/components/asset-manager.tsx @@ -0,0 +1,485 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { 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"; + +type AssetView = "assets" | "tasks"; +const ASSET_PAGE_SIZE = 8; +const TASK_PAGE_SIZE = 8; + +export function AssetManager() { + const [assets, setAssets] = useState([]); + const [jobs, setJobs] = useState([]); + const [view, setView] = useState("assets"); + const [loading, setLoading] = useState(false); + const [syncingJobId, setSyncingJobId] = useState(null); + const [deletingAssetId, setDeletingAssetId] = useState(null); + const [deletingJobId, setDeletingJobId] = useState(null); + const [previewAsset, setPreviewAsset] = useState(null); + const [expandedJobId, setExpandedJobId] = useState(null); + const [assetPage, setAssetPage] = useState(1); + const [jobPage, setJobPage] = useState(1); + const [error, setError] = useState(null); + const managerRef = useRef(null); + const listRef = useRef(null); + const feedbackRef = useRef(null); + const previewDialogRef = useRef(null); + const closeButtonRef = useRef(null); + + useEffect(() => { + refresh().catch(() => undefined); + }, []); + + const jobByOutputAssetId = useMemo(() => { + const map = new Map(); + for (const job of jobs) { + for (const assetId of job.outputAssetIds) map.set(assetId, job); + } + return map; + }, [jobs]); + + const visibleAssets = pageItems(assets, assetPage, ASSET_PAGE_SIZE); + const visibleJobs = pageItems(jobs, jobPage, TASK_PAGE_SIZE); + + useEffect(() => { + setAssetPage((page) => clampPage(page, assets.length, ASSET_PAGE_SIZE)); + }, [assets.length]); + + useEffect(() => { + setJobPage((page) => clampPage(page, jobs.length, TASK_PAGE_SIZE)); + }, [jobs.length]); + + useEffect(() => { + if (view === "assets") setAssetPage((page) => clampPage(page, assets.length, ASSET_PAGE_SIZE)); + if (view === "tasks") setJobPage((page) => clampPage(page, jobs.length, TASK_PAGE_SIZE)); + }, [view, assets.length, jobs.length]); + + useEffect(() => { + return runScopedMotion(managerRef, (scope) => revealChildren(scope)); + }, []); + + useEffect(() => { + if (listRef.current) revealChildren(listRef.current, ".asset-history-card, .compact-job, .job-detail-panel"); + }, [view, assetPage, jobPage, visibleAssets.length, visibleJobs.length, expandedJobId]); + + useEffect(() => { + pulseFeedback(feedbackRef.current); + }, [error]); + + useEffect(() => { + if (!previewAsset) return undefined; + modalEnter(previewDialogRef.current); + window.requestAnimationFrame(() => closeButtonRef.current?.focus()); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") closePreview(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [previewAsset]); + + async function refresh() { + setLoading(true); + setError(null); + try { + const [assetResponse, imageResponse, videoResponse] = await Promise.all([ + fetch("/api/assets", { cache: "no-store" }), + fetch("/api/generations/image", { cache: "no-store" }), + fetch("/api/generations/video", { cache: "no-store" }) + ]); + const [assetPayload, imagePayload, videoPayload] = await Promise.all([ + assetResponse.json(), + imageResponse.json(), + videoResponse.json() + ]); + if (!assetResponse.ok) throw new Error(assetPayload.error || "读取资产失败"); + if (!imageResponse.ok) throw new Error(imagePayload.error || "读取图片任务失败"); + if (!videoResponse.ok) throw new Error(videoPayload.error || "读取视频任务失败"); + const dedupedJobs = new Map(); + for (const job of [...(imagePayload.jobs || []), ...(videoPayload.jobs || [])] as GenerationJob[]) { + dedupedJobs.set(job.id, job); + } + const nextJobs = [...dedupedJobs.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + const outputAssetIds = new Set(nextJobs.flatMap((job) => job.outputAssetIds)); + setAssets((assetPayload.assets || []).filter((asset: Asset) => isResultAsset(asset, outputAssetIds))); + setJobs(nextJobs); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + } + + async function syncJob(job: GenerationJob) { + setSyncingJobId(job.id); + setError(null); + try { + const response = await fetch(jobPath(job), { cache: "no-store" }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "查询任务失败"); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSyncingJobId(null); + } + } + + async function removeAsset(asset: Asset) { + if (!window.confirm(`删除「${asset.name}」?对应 OSS 对象会一并删除。`)) return; + setDeletingAssetId(asset.id); + setError(null); + try { + const response = await fetch(`/api/assets/${asset.id}`, { method: "DELETE" }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "删除资产失败"); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setDeletingAssetId(null); + } + } + + async function removeJob(job: GenerationJob) { + if (!window.confirm(`删除「${capabilityLabel(job.capability)}」任务?任务输出结果和对应 OSS 对象会一并删除。`)) return; + setDeletingJobId(job.id); + setError(null); + try { + const response = await fetch(jobPath(job), { method: "DELETE" }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "删除任务失败"); + if (expandedJobId === job.id) setExpandedJobId(null); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setDeletingJobId(null); + } + } + + function closePreview() { + modalExit(previewDialogRef.current, () => setPreviewAsset(null)); + } + + return ( +
+
+
+

结果资产

+
+
+ +
+
+
+ + +
+ +
+ + {error ?
{error}
: null} + + {view === "assets" ? ( + <> +
+ {visibleAssets.map((asset) => ( +
+ +
+
+

{asset.name}

+ {sourceLabel(asset.source)} +
+

{kindLabel(asset)} / {formatDate(asset.createdAt)}

+
+ + + {jobByOutputAssetId.get(asset.id) ? ( + {capabilityLabel(jobByOutputAssetId.get(asset.id)?.capability)} + ) : null} +
+
+
+ ))} + {!assets.length ?
暂无生成结果。提交创作、局部重绘或超清后,结果会自动保留在这里。
: null} +
+ + + ) : ( + <> +
+ {visibleJobs.map((job) => ( +
+
+
+

{job.prompt?.slice(0, 90) || capabilityLabel(job.capability)}

+

{capabilityLabel(job.capability)} / {job.reqKey}

+

{durationLabel(job)} / 输入 {job.inputAssetIds.length || job.inputUrls.length} 个,输出 {job.outputAssetIds.length} 个

+
+
+ {statusLabel(job.status)} + + + +
+
+ {expandedJobId === job.id ? : null} +
+ ))} + {!jobs.length ?
暂无生成任务。提交图片或视频生成后会保留任务历史。
: null} +
+ + + )} +
+ {previewAsset ? ( +
+
event.stopPropagation()}> +
+
+ {previewAsset.name} + {kindLabel(previewAsset)} / {sourceLabel(previewAsset.source)} / {formatDate(previewAsset.createdAt)} +
+ +
+
+ {renderAssetPreviewLarge(previewAsset)} +
+
+
+ ) : null} +
+ ); +} + +function JobDetails({ job }: { job: GenerationJob }) { + return ( +
+
+
+
状态
+
{statusLabel(job.status)}
+
+
+
耗时
+
{durationValue(job)}
+
+
+
提交时间
+
{formatDateTime(job.createdAt)}
+
+
+
更新时间
+
{formatDateTime(job.updatedAt)}
+
+
+
任务类型
+
{capabilityLabel(job.capability)}
+
+
+
服务
+
{providerLabel(job.provider)}
+
+
+
Req Key
+
{job.reqKey}
+
+
+
任务 ID
+
{job.providerTaskId || job.id}
+
+
+
输入/输出
+
{job.inputAssetIds.length || job.inputUrls.length} / {job.outputAssetIds.length}
+
+ {job.error ? ( +
+
错误
+
{job.error.message}
+
+ ) : null} +
+ {job.prompt ? ( +
+ 提示词 +

{job.prompt}

+
+ ) : null} +
+ ); +} + +function renderAssetPreview(asset: Asset) { + if (isAudio(asset)) { + return ( +
+ + 音频素材 +
+ ); + } + if (asset.kind === "video" || isVideo(asset)) { + return