Initial AI manga platform

This commit is contained in:
www
2026-06-15 17:45:28 +08:00
commit 7a8191650f
267 changed files with 105987 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Manga User App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "AI Manga",
"appid": "__UNI__AI_MANGA",
"description": "AI manga short drama user app, H5 first with mini program and app route reservations",
"versionName": "0.1.0",
"versionCode": "100",
"transformPx": false,
"h5": {
"title": "AI Manga"
},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
}
},
"app-plus": {
"safearea": {
"bottom": {
"offset": "auto"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "user-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev:h5": "vite --host 0.0.0.0 --port ${USER_APP_PORT:-5174}",
"build": "vue-tsc --noEmit -p tsconfig.json && vite build",
"lint": "vue-tsc --noEmit -p tsconfig.json",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"vue": "^3.5.16"
}
}
+106
View File
@@ -0,0 +1,106 @@
{
"pages": [
{
"path": "src/pages/index/index",
"style": {
"navigationBarTitleText": "漫剧制作台"
}
},
{
"path": "src/pages/auth/login",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "src/pages/help/tutorial",
"style": {
"navigationBarTitleText": "新手教程"
}
},
{
"path": "src/pages/projects/create",
"style": {
"navigationBarTitleText": "新建项目"
}
},
{
"path": "src/pages/user/projects",
"style": {
"navigationBarTitleText": "我的项目"
}
},
{
"path": "src/pages/projects/source-select",
"style": {
"navigationBarTitleText": "来源选择"
}
},
{
"path": "src/pages/projects/original-setting",
"style": {
"navigationBarTitleText": "原创设置"
}
},
{
"path": "src/pages/projects/upload-novel",
"style": {
"navigationBarTitleText": "上传小说"
}
},
{
"path": "src/pages/projects/copyright",
"style": {
"navigationBarTitleText": "版权确认"
}
},
{
"path": "src/pages/projects/story-bible",
"style": {
"navigationBarTitleText": "故事圣经"
}
},
{
"path": "src/pages/projects/characters",
"style": {
"navigationBarTitleText": "角色库"
}
},
{
"path": "src/pages/projects/episodes",
"style": {
"navigationBarTitleText": "分集计划"
}
},
{
"path": "src/pages/projects/storyboard",
"style": {
"navigationBarTitleText": "脚本分镜"
}
},
{
"path": "src/pages/projects/progress",
"style": {
"navigationBarTitleText": "生成进度"
}
},
{
"path": "src/pages/projects/result",
"style": {
"navigationBarTitleText": "成品漫剧"
}
},
{
"path": "src/pages/user/profile",
"style": {
"navigationBarTitleText": "用户中心"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "漫剧制作台",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f4f6f8"
}
}
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from './pages/index/index.vue';
</script>
<template>
<IndexPage />
</template>
File diff suppressed because it is too large Load Diff
+307
View File
@@ -0,0 +1,307 @@
interface ApiEnvelope<T> {
code: number;
message: string;
data: T;
request_id: string;
}
interface ApiCryptoHandshake {
version: number;
algorithm: string;
session_id: string;
server_public_key: JsonWebKey;
salt: string;
expires_at: string;
}
interface ClientConfig {
api_crypto_enabled: boolean;
api_crypto_mode: string;
api_crypto_session_ttl_seconds: number;
}
interface ApiCryptoEnvelope {
encrypted?: boolean;
version: number;
session_id: string;
client_public_key?: JsonWebKey;
iv: string;
ciphertext: string;
}
interface ApiCryptoSession {
sessionId: string;
clientPublicKey: JsonWebKey;
aesKey: CryptoKey;
expiresAt: number;
}
export interface EncryptedUploadFilePayload {
original_name: string;
mime_type: string;
size: number;
content_base64: string;
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function getWebCrypto() {
if (!globalThis.crypto?.subtle) {
throw new Error('当前浏览器不支持 API 加密所需的 WebCrypto。');
}
return globalThis.crypto;
}
function bytesToBinary(bytes: Uint8Array) {
let binary = '';
const chunkSize = 0x8000;
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
}
return binary;
}
function binaryToBytes(binary: string) {
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function bytesToBase64Url(bytes: Uint8Array) {
return btoa(bytesToBinary(bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function base64UrlToBytes(value: string) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
return binaryToBytes(atob(padded));
}
export function bytesToBase64(bytes: Uint8Array) {
return btoa(bytesToBinary(bytes));
}
export function base64ToBytes(value: string) {
return binaryToBytes(atob(value));
}
export async function fileToEncryptedUploadPayload(file: File): Promise<EncryptedUploadFilePayload> {
return {
original_name: file.name,
mime_type: file.type || 'application/octet-stream',
size: file.size,
content_base64: bytesToBase64(new Uint8Array(await file.arrayBuffer()))
};
}
function jsonToBase64Url(value: unknown) {
return bytesToBase64Url(encoder.encode(JSON.stringify(value)));
}
function isEncryptedEnvelope(value: unknown): value is ApiCryptoEnvelope {
if (typeof value !== 'object' || value === null) return false;
const record = value as Record<string, unknown>;
return (
record.version === 1 &&
typeof record.session_id === 'string' &&
typeof record.iv === 'string' &&
typeof record.ciphertext === 'string'
);
}
export class ApiCryptoClient {
private session: ApiCryptoSession | null = null;
private pendingSession: Promise<ApiCryptoSession> | null = null;
private enabledCache: { value: boolean; expiresAt: number } | null = null;
constructor(private readonly baseUrl: string) {}
async encryptionHeaders() {
if (!(await this.isEnabled())) {
return {};
}
const session = await this.getSession();
return this.buildHeaders(session);
}
async encryptBody(body: unknown) {
if (!(await this.isEnabled())) {
return null;
}
const session = await this.getSession();
const crypto = getWebCrypto();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
session.aesKey,
encoder.encode(JSON.stringify(body ?? null))
);
return {
body: {
version: 1,
session_id: session.sessionId,
client_public_key: session.clientPublicKey,
iv: bytesToBase64Url(iv),
ciphertext: bytesToBase64Url(new Uint8Array(ciphertext))
},
headers: this.buildHeaders(session)
};
}
async isEnabled() {
const mode = this.configuredMode();
if (['1', 'true', 'yes', 'on'].includes(mode)) return true;
if (['0', 'false', 'no', 'off'].includes(mode)) return false;
const now = Date.now();
if (this.enabledCache && this.enabledCache.expiresAt > now) {
return this.enabledCache.value;
}
try {
const response = await fetch(`${this.baseUrl}/client-config`, {
headers: { accept: 'application/json' }
});
const envelope = (await response.json()) as ApiEnvelope<ClientConfig>;
const value = Boolean(response.ok && envelope.code === 0 && envelope.data.api_crypto_enabled);
this.enabledCache = {
value,
expiresAt: now + 3000
};
return value;
} catch {
this.enabledCache = {
value: false,
expiresAt: now + 3000
};
return false;
}
}
async decryptResponse<T>(payload: unknown) {
if (!isEncryptedEnvelope(payload)) {
return payload as T;
}
const session = await this.getSession(payload.session_id);
const plaintext = await getWebCrypto().subtle.decrypt(
{ name: 'AES-GCM', iv: base64UrlToBytes(payload.iv) },
session.aesKey,
base64UrlToBytes(payload.ciphertext)
);
return JSON.parse(decoder.decode(plaintext)) as T;
}
private async getSession(expectedSessionId?: string) {
const now = Date.now();
if (
this.session &&
this.session.expiresAt > now &&
(!expectedSessionId || this.session.sessionId === expectedSessionId)
) {
return this.session;
}
if (expectedSessionId) {
throw new Error('API 加密会话已失效,请刷新页面后重试。');
}
if (!this.pendingSession) {
this.pendingSession = this.createSession().finally(() => {
this.pendingSession = null;
});
}
this.session = await this.pendingSession;
return this.session;
}
private async createSession() {
const response = await fetch(`${this.baseUrl}/crypto/handshake`, {
headers: { accept: 'application/json' }
});
const envelope = (await response.json()) as ApiEnvelope<ApiCryptoHandshake>;
if (!response.ok || envelope.code !== 0) {
throw new Error(envelope.message || `API 加密握手失败:HTTP ${response.status}`);
}
const crypto = getWebCrypto();
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveBits']
);
const serverPublicKey = await crypto.subtle.importKey(
'jwk',
envelope.data.server_public_key,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
const sharedBits = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: serverPublicKey },
keyPair.privateKey,
256
);
const hkdfKey = await crypto.subtle.importKey('raw', sharedBits, 'HKDF', false, [
'deriveKey'
]);
const aesKey = await crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: base64UrlToBytes(envelope.data.salt),
info: encoder.encode(`ai-manga-api-v1:${envelope.data.session_id}`)
},
hkdfKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
const clientPublicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
return {
sessionId: envelope.data.session_id,
clientPublicKey,
aesKey,
expiresAt: Date.parse(envelope.data.expires_at) - 30_000
};
}
private buildHeaders(session: ApiCryptoSession) {
return {
'x-api-encrypted': 'v1',
'x-api-session-id': session.sessionId,
'x-api-client-public-key': jsonToBase64Url(session.clientPublicKey)
};
}
private configuredMode() {
return ((import.meta.env.VITE_API_CRYPTO_ENABLED as string | undefined) || 'auto')
.trim()
.toLowerCase();
}
}
+6
View File
@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';
createApp(App).mount('#app');
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
const tutorialConcepts = [
{
title: '故事圣经',
text: '整部作品的设定规则书,包含主线、世界观、风格、禁区和改编口径。'
},
{
title: '角色圣经',
text: '角色库,记录主角、反派、配角的身份、外貌、关系和一致性要求。'
},
{
title: '场景',
text: '场景字段由 AI 在脚本和分镜阶段生成,包括场景名、地点、画面、动作和镜头运动。'
},
{
title: '额度',
text: '内部测试不展示支付,余额由后台人工增加。真实图片、语音和视频生成会消耗额度。'
}
];
const tutorialSteps = [
'新建项目,选择 AI 原创小说或上传小说改编。',
'准备小说来源;上传小说先做版权确认,再解析章节。',
'生成并确认故事圣经,统一整部作品的方向。',
'抽取并确认角色库,需要一致性的角色再生成锚点图。',
'生成分集计划、单集脚本和分镜。',
'确认分镜后生成图片、音频、字幕并合成视频。',
'完成文本/视频审核后预览、下载或授权公开案例。'
];
</script>
<template>
<main class="user-shell">
<section class="app-main tutorial-page">
<section class="panel tutorial-hero">
<div>
<span class="eyebrow">Guide</span>
<h1>新手教程</h1>
<p>先看懂这些节点再开始跑一集详细版在项目根目录 OPERATION_GUIDE.md</p>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<span class="eyebrow">Concepts</span>
<h2>核心概念</h2>
</div>
</div>
<div class="card-grid">
<article v-for="item in tutorialConcepts" :key="item.title" class="mini-card tutorial-card">
<strong>{{ item.title }}</strong>
<p>{{ item.text }}</p>
</article>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<span class="eyebrow">Flow</span>
<h2>一集操作顺序</h2>
</div>
</div>
<div class="tutorial-steps">
<article v-for="(step, index) in tutorialSteps" :key="step" class="tutorial-step">
<span>{{ index + 1 }}</span>
<div>
<strong> {{ index + 1 }} </strong>
<p>{{ step }}</p>
</div>
</article>
</div>
</section>
</section>
</main>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import IndexPage from '../index/index.vue';
</script>
<template>
<IndexPage />
</template>
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
import type { MediaAssetRow, SafeProject, SafeRenderTask } from './api/client';
export const projectStatusLabels: Record<string, string> = {
source_selecting: '选择来源',
novel_generating: '原创小说',
novel_uploaded: '文本就绪',
copyright_pending: '版权确认',
copyright_confirmed: '版权已确认',
text_parsing: '文本解析',
text_parse_failed: '解析失败',
story_bible_generating: '故事圣经',
waiting_story_confirm: '待确认故事',
story_confirmed: '故事已确认',
character_extracting: '角色抽取',
character_generating: '角色生成',
waiting_character_confirm: '待确认角色',
character_confirmed: '角色已确认',
episode_planning: '分集计划',
waiting_episode_confirm: '待确认分集',
episode_confirmed: '分集已确认',
script_generating: '脚本生成',
waiting_script_confirm: '待确认脚本',
script_confirmed: '脚本已确认',
storyboard_generating: '分镜生成',
waiting_storyboard_confirm: '待确认分镜',
storyboard_confirmed: '分镜已确认',
preview_images_generated: '预览图完成',
final_images_generated: '正式图完成',
audio_generated: '音频完成',
subtitle_generated: '字幕完成',
video_rendered: '视频完成',
actor_profile_generated: '演员定妆',
live_action_shots_prepared: '真人分镜',
live_action_keyframes_generated: '关键帧',
live_action_clips_generated: '视频片段',
live_action_video_rendered: '真人短剧',
completed: '已完成',
manual_required: '人工介入',
failed: '失败',
cancelled: '已取消',
archived: '已归档'
};
export const taskTypeLabels: Record<string, string> = {
novel_generate: '小说生成',
novel_parse: '小说解析',
story_bible_generate: '故事圣经',
character_extract: '角色抽取',
episode_plan_generate: '分集计划',
script_generate: '单集脚本',
storyboard_generate: '分镜脚本',
character_image_generate: '角色图',
shot_image_generate: '分镜图',
audio_generate: '多角色音频',
subtitle_generate: '字幕',
video_render: '视频合成',
live_action_keyframe_generate: '真人关键帧',
live_action_video_clip_generate: '真人视频片段',
live_action_video_render: '真人短剧合成',
qc_check: '质检',
manual_review: '人工审核'
};
export const workflowSteps = [
{ key: 'source', label: '来源' },
{ key: 'copyright', label: '版权' },
{ key: 'story', label: '故事' },
{ key: 'characters', label: '角色' },
{ key: 'memory', label: '记忆' },
{ key: 'episodes', label: '分集' },
{ key: 'script', label: '脚本' },
{ key: 'storyboard', label: '分镜' },
{ key: 'images', label: '图片' },
{ key: 'media', label: '音频字幕' },
{ key: 'result', label: '成品' }
] as const;
const statusStepIndex: Record<string, number> = {
source_selecting: 0,
novel_generating: 0,
novel_uploaded: 1,
copyright_pending: 1,
copyright_confirmed: 1,
text_parsing: 1,
story_bible_generating: 2,
waiting_story_confirm: 2,
story_confirmed: 3,
character_extracting: 3,
character_generating: 3,
waiting_character_confirm: 3,
character_confirmed: 4,
episode_planning: 5,
waiting_episode_confirm: 5,
episode_confirmed: 6,
script_generating: 6,
waiting_script_confirm: 6,
script_confirmed: 7,
storyboard_generating: 7,
waiting_storyboard_confirm: 7,
storyboard_confirmed: 8,
character_image_generated: 8,
preview_images_generated: 8,
final_images_generated: 9,
audio_generated: 9,
subtitle_generated: 9,
video_rendered: 10,
actor_profile_generated: 4,
live_action_shots_prepared: 8,
live_action_keyframes_generated: 8,
live_action_clips_generated: 9,
live_action_video_rendered: 10,
completed: 10
};
export function labelProjectStatus(status?: string | null) {
return status ? projectStatusLabels[status] ?? status : '未开始';
}
export function labelTaskType(taskType: string) {
return taskTypeLabels[taskType] ?? taskType;
}
export function taskStatusTone(status: string) {
if (status === 'success') return 'success';
if (status === 'failed' || status === 'manual_required') return 'danger';
if (status === 'running' || status === 'retrying') return 'running';
return 'muted';
}
export function progressOf(project: SafeProject | null, tasks: SafeRenderTask[], mediaAssets: MediaAssetRow[]) {
if (!project) return 0;
const baseIndex = statusStepIndex[project.status] ?? 0;
const base = Math.round((baseIndex / (workflowSteps.length - 1)) * 100);
const successfulTasks = tasks.filter((task) => task.status === 'success').length;
const taskBonus = Math.min(12, successfulTasks * 2);
const hasVideo = mediaAssets.some((row) => row.asset.asset_type === 'video');
return Math.min(100, Math.max(base, hasVideo ? 100 : base + taskBonus));
}
export function formatBytes(size: string | null) {
const value = Number(size ?? 0);
if (!Number.isFinite(value) || value <= 0) return '-';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"jsx": "preserve",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"vite/client",
"vitest"
],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": [
"src/**/*.ts",
"src/**/*.vue",
"vite.config.ts"
]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: Number(process.env.USER_APP_PORT ?? 5174)
}
});