Initial 智念AIGC platform
This commit is contained in:
68
tests/data-store-concurrency.test.ts
Normal file
68
tests/data-store-concurrency.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createGenerationJob,
|
||||
listGenerationJobs,
|
||||
updateGenerationJob
|
||||
} from "@/lib/server/data-store";
|
||||
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
|
||||
|
||||
let runtimeDir = "";
|
||||
let previousRuntimeDir: string | undefined;
|
||||
let previousSupabaseUrl: string | undefined;
|
||||
let previousSupabaseKey: string | undefined;
|
||||
|
||||
describe("local data store concurrency", () => {
|
||||
beforeEach(async () => {
|
||||
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-store-"));
|
||||
previousRuntimeDir = process.env.NIANXXPLAY_RUNTIME_DIR;
|
||||
previousSupabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
previousSupabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
process.env.NIANXXPLAY_RUNTIME_DIR = runtimeDir;
|
||||
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv("NIANXXPLAY_RUNTIME_DIR", previousRuntimeDir);
|
||||
restoreEnv("NEXT_PUBLIC_SUPABASE_URL", previousSupabaseUrl);
|
||||
restoreEnv("SUPABASE_SERVICE_ROLE_KEY", previousSupabaseKey);
|
||||
await rm(runtimeDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("serializes concurrent job writes without losing records", async () => {
|
||||
const created = await Promise.all(Array.from({ length: 8 }, (_, index) => createGenerationJob({
|
||||
ownerId: DEFAULT_OWNER_ID,
|
||||
capability: "image.generate",
|
||||
provider: "mock",
|
||||
reqKey: "jimeng_seedream46_cvtob",
|
||||
status: "queued",
|
||||
prompt: `job ${index}`,
|
||||
inputAssetIds: [],
|
||||
inputUrls: [],
|
||||
outputAssetIds: [],
|
||||
requestPayload: { index }
|
||||
})));
|
||||
|
||||
await Promise.all(created.map((job, index) => updateGenerationJob(job.id, {
|
||||
status: "succeeded",
|
||||
outputAssetIds: [`asset-${index}`]
|
||||
})));
|
||||
|
||||
const stored = await listGenerationJobs(DEFAULT_OWNER_ID, 20);
|
||||
const storedById = new Map(stored.map((job) => [job.id, job]));
|
||||
for (const job of created) {
|
||||
expect(storedById.get(job.id)?.status).toBe("succeeded");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
return;
|
||||
}
|
||||
process.env[name] = value;
|
||||
}
|
||||
68
tests/evolink-image-client.test.ts
Normal file
68
tests/evolink-image-client.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildEvolinkImagePayload,
|
||||
extractEvolinkResultUrls,
|
||||
getEvolinkTaskId,
|
||||
mapEvolinkStatus
|
||||
} from "@/lib/evolink/image-client";
|
||||
|
||||
describe("EvoLink image client helpers", () => {
|
||||
it("builds payloads for GPT Image 2 generation", () => {
|
||||
const payload = buildEvolinkImagePayload("image.generate", {
|
||||
prompt: "商品海报",
|
||||
imageUrls: ["https://example.com/ref.png"],
|
||||
width: 2048,
|
||||
height: 2048
|
||||
}, {
|
||||
baseUrl: "https://api.evolink.ai",
|
||||
model: "gpt-image-2",
|
||||
quality: "medium",
|
||||
resolution: "2K"
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
model: "gpt-image-2",
|
||||
prompt: "商品海报",
|
||||
image_urls: ["https://example.com/ref.png"],
|
||||
size: "1:1",
|
||||
quality: "medium",
|
||||
resolution: "2K",
|
||||
n: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("maps inpainting original and mask URLs", () => {
|
||||
const payload = buildEvolinkImagePayload("image.inpaint", {
|
||||
prompt: "移除背景杂物",
|
||||
imageUrls: ["https://example.com/original.png", "https://example.com/mask.png"]
|
||||
}, {
|
||||
baseUrl: "https://api.evolink.ai",
|
||||
model: "gpt-image-2"
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
image_urls: ["https://example.com/original.png"],
|
||||
mask_url: "https://example.com/mask.png"
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes task ids, statuses, and result URLs", () => {
|
||||
const response = {
|
||||
data: {
|
||||
task_id: "task-1",
|
||||
status: "completed",
|
||||
results: [
|
||||
"https://example.com/a.png",
|
||||
{ image_url: "https://example.com/b.png" }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
expect(getEvolinkTaskId(response)).toBe("task-1");
|
||||
expect(mapEvolinkStatus(response)).toBe("succeeded");
|
||||
expect(extractEvolinkResultUrls(response)).toEqual([
|
||||
"https://example.com/a.png",
|
||||
"https://example.com/b.png"
|
||||
]);
|
||||
});
|
||||
});
|
||||
58
tests/jimeng-capabilities.test.ts
Normal file
58
tests/jimeng-capabilities.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJimengPayload,
|
||||
buildJimengQueryPayload,
|
||||
getJimengCapabilities,
|
||||
getVisibleImageCapabilities
|
||||
} from "@/lib/jimeng/capabilities";
|
||||
|
||||
describe("Jimeng capability matrix", () => {
|
||||
it("only exposes the three supported image capabilities", () => {
|
||||
const capabilities = getJimengCapabilities();
|
||||
expect(Object.keys(capabilities)).toEqual([
|
||||
"image.generate",
|
||||
"image.inpaint",
|
||||
"image.upscale"
|
||||
]);
|
||||
expect(getVisibleImageCapabilities().map((capability) => capability.id)).toEqual([
|
||||
"image.generate",
|
||||
"image.inpaint",
|
||||
"image.upscale"
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds payloads for image generation 4.6", () => {
|
||||
const payload = buildJimengPayload("image.generate", "jimeng_seedream46_cvtob", {
|
||||
prompt: "商品海报",
|
||||
imageUrls: ["https://example.com/ref.png"],
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
force_single: true,
|
||||
scale: 50
|
||||
});
|
||||
expect(payload).toMatchObject({
|
||||
req_key: "jimeng_seedream46_cvtob",
|
||||
prompt: "商品海报",
|
||||
image_urls: ["https://example.com/ref.png"],
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
force_single: true,
|
||||
scale: 50
|
||||
});
|
||||
});
|
||||
|
||||
it("requires original and mask URLs for inpainting", () => {
|
||||
expect(() =>
|
||||
buildJimengPayload("image.inpaint", "jimeng_image2image_dream_inpaint", {
|
||||
imageUrls: ["https://example.com/original.png"]
|
||||
})
|
||||
).toThrow(/exactly two/);
|
||||
});
|
||||
|
||||
it("uses return_url query payload for polling", () => {
|
||||
const payload = buildJimengQueryPayload("jimeng_i2i_seed3_tilesr_cvtob", "task-1");
|
||||
expect(payload.req_key).toBe("jimeng_i2i_seed3_tilesr_cvtob");
|
||||
expect(payload.task_id).toBe("task-1");
|
||||
expect(String(payload.req_json)).toContain('"return_url":true');
|
||||
});
|
||||
});
|
||||
44
tests/prompt-assembler.test.ts
Normal file
44
tests/prompt-assembler.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assemblePrompt, extractMaterialRequirements } from "@/lib/prompt/assembler";
|
||||
|
||||
describe("prompt assembler", () => {
|
||||
it("preserves storyboard cards and @material references for video", () => {
|
||||
const result = assemblePrompt({
|
||||
mode: "video",
|
||||
projectName: "咖啡门店",
|
||||
audience: "周边上班族",
|
||||
offer: "新品拿铁",
|
||||
storyboard: [
|
||||
{ id: "s1", title: "开场", visual: "门店外立面,参考@图片1", camera: "推进" },
|
||||
{ id: "s2", title: "新品", visual: "新品拿铁特写,参考@图片2", caption: "今日新品" }
|
||||
],
|
||||
materials: [
|
||||
{ type: "image", url: "https://example.com/a.png", label: "@图片1" },
|
||||
{ type: "image", url: "https://example.com/b.png", label: "@图片2" }
|
||||
]
|
||||
});
|
||||
expect(result.prompt).toContain("咖啡门店");
|
||||
expect(result.prompt).toContain("@图片1");
|
||||
expect(result.prompt).toContain("@图片2");
|
||||
expect(result.prompt).toContain("镜头=推进");
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns when prompt references unbound materials", () => {
|
||||
const result = assemblePrompt({
|
||||
mode: "image",
|
||||
projectName: "品牌海报",
|
||||
manualPrompt: "使用@图片2生成主视觉",
|
||||
materials: [{ type: "image", url: "https://example.com/a.png", label: "@图片1" }]
|
||||
});
|
||||
expect(result.warnings[0]).toContain("@图片2");
|
||||
});
|
||||
|
||||
it("extracts image, video, and audio requirements", () => {
|
||||
expect(extractMaterialRequirements("参考@图片3、@视频2和@音频1")).toEqual({
|
||||
image: 3,
|
||||
video: 2,
|
||||
audio: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
37
tests/video-settings.test.ts
Normal file
37
tests/video-settings.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
VIDEO_DURATION_AUTO,
|
||||
VIDEO_DURATION_DEFAULT,
|
||||
VIDEO_DURATION_MAX,
|
||||
VIDEO_DURATION_MIN,
|
||||
clampVideoDuration,
|
||||
normalizeVideoDuration,
|
||||
normalizeVideoRatio,
|
||||
normalizeVideoResolution
|
||||
} from "@/lib/video-settings";
|
||||
|
||||
describe("video settings", () => {
|
||||
it("clamps duration to the supported range", () => {
|
||||
expect(clampVideoDuration(2)).toBe(VIDEO_DURATION_MIN);
|
||||
expect(clampVideoDuration(999)).toBe(VIDEO_DURATION_MAX);
|
||||
expect(clampVideoDuration(12.6)).toBe(13);
|
||||
});
|
||||
|
||||
it("supports Seedance auto duration only when allowed", () => {
|
||||
expect(normalizeVideoDuration(VIDEO_DURATION_AUTO)).toBe(VIDEO_DURATION_AUTO);
|
||||
expect(clampVideoDuration(VIDEO_DURATION_AUTO, VIDEO_DURATION_DEFAULT, { allowAuto: false })).toBe(VIDEO_DURATION_MIN);
|
||||
});
|
||||
|
||||
it("keeps empty duration optional and falls back for invalid values", () => {
|
||||
expect(normalizeVideoDuration(undefined)).toBeUndefined();
|
||||
expect(normalizeVideoDuration("")).toBeUndefined();
|
||||
expect(clampVideoDuration("not-a-number")).toBe(VIDEO_DURATION_DEFAULT);
|
||||
});
|
||||
|
||||
it("normalizes ratio and resolution to Seedance 2.0 supported values", () => {
|
||||
expect(normalizeVideoRatio("21:9")).toBe("21:9");
|
||||
expect(normalizeVideoRatio("bad-ratio")).toBe("9:16");
|
||||
expect(normalizeVideoResolution("1080p", "doubao-seedance-2-0-260128")).toBe("1080p");
|
||||
expect(normalizeVideoResolution("1080p", "doubao-seedance-2-0-fast-260128")).toBe("720p");
|
||||
});
|
||||
});
|
||||
25
tests/volcengine-signature.test.ts
Normal file
25
tests/volcengine-signature.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sha256Hex, signVolcengineRequest } from "@/lib/volcengine/signature";
|
||||
|
||||
describe("Volcengine Visual signing", () => {
|
||||
it("creates canonical request and signed headers", () => {
|
||||
const signed = signVolcengineRequest({
|
||||
method: "POST",
|
||||
endpoint: "https://visual.volcengineapi.com",
|
||||
query: {
|
||||
Version: "2022-08-31",
|
||||
Action: "CVSync2AsyncSubmitTask"
|
||||
},
|
||||
body: JSON.stringify({ req_key: "jimeng_seedream46_cvtob", prompt: "test" }),
|
||||
accessKeyId: "ak",
|
||||
secretAccessKey: "sk",
|
||||
region: "cn-north-1",
|
||||
service: "cv",
|
||||
date: new Date("2026-05-28T00:00:00Z")
|
||||
});
|
||||
expect(signed.url).toBe("https://visual.volcengineapi.com/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31");
|
||||
expect(signed.headers.Authorization).toContain("HMAC-SHA256 Credential=ak/20260528/cn-north-1/cv/request");
|
||||
expect(signed.headers["X-Content-Sha256"]).toBe(sha256Hex(JSON.stringify({ req_key: "jimeng_seedream46_cvtob", prompt: "test" })));
|
||||
expect(signed.canonicalRequest).toContain("content-type;host;x-content-sha256;x-date");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user