Sistine Starter

AI 图像生成功能

基于火山引擎豆包 API 的图生图实现指南

AI 图像生成功能

Sistine Starter 集成了火山引擎豆包 API 的图生图 (Image-to-Image) 功能,支持基于参考图像进行 AI 图像编辑和生成。本文档详细介绍图像生成系统的架构、实现细节和集成方法。

功能概览

核心特性

  • 图生图 (I2I): 基于参考图像进行编辑和生成
  • 积分扣费: 每次生成消耗 20 积分,事务性扣费确保数据一致性
  • 结果存储: 生成结果自动上传到 R2 存储,长期保存
  • 生成历史: 完整记录每次生成的参数和结果
  • 多尺寸支持: 支持 adaptive / 1K / 2K / 4K 分辨率
  • 水印控制: 可选择是否添加水印

技术架构

用户上传图像 + 输入提示词

身份认证 (Better Auth)

检查积分余额 (20 积分)

创建生成记录 (generationHistory)

扣除积分 + 记录账本

调用火山引擎图像 API

上传结果到 R2 存储

更新生成记录 (completed)

返回结果 URL

火山引擎配置

模型选择

当前使用的模型是 doubao-seededit-3-0-i2i-250628,这是火山引擎豆包系列的图生图专用模型。

模型特性:

  • 支持基于参考图像的编辑和生成
  • 高质量输出,细节保留良好
  • 支持多种分辨率 (最高 4K)
  • 生成速度: 约 5-10 秒/张

配置位置: lib/volcano-engine/image.ts:20

const model = volcanoEngineConfig.imageModel || 'doubao-seededit-3-0-i2i-250628';

API 参数配置

可用参数:

参数类型默认值说明
promptstring必需图像编辑的提示词描述
imagestring[]必需参考图像 URL 数组 (图生图模式)
sizestringadaptive输出分辨率 (adaptive / 1K / 2K / 4K)
watermarkbooleantrue是否添加水印
response_formatstringurl返回格式 (固定为 url)

调整参数示例:

// lib/volcano-engine/image.ts
const request: ImageGenerationRequest = {
  model,
  prompt,
  image: [referenceImageUrl],  // 参考图像
  response_format: 'url',
  size: '2K',                   // 使用 2K 分辨率
  watermark: false,             // 不添加水印
};

分辨率说明

选项说明适用场景
adaptive自适应 (根据输入图像自动选择)通用场景,推荐默认值
1K约 1024x1024快速预览,节省积分
2K约 2048x2048高质量输出
4K约 4096x4096超高清输出 (消耗更多时间)

数据库架构

生成历史表 (generationHistory)

存储所有 AI 生成操作的记录 (包括图像和视频)。建表语句请参考 数据库 文档中的 generationHistory 表定义。

关键字段说明:

字段类型说明
typeTEXT'image' (图像生成) 或 'video' (视频生成)
promptTEXT用户输入的提示词描述
imageUrlTEXT参考图像 URL (图生图模式必需)
resultUrlTEXT生成完成后的结果 URL (存储在 R2)
statusTEXT任务状态: processingcompleted / failed
creditsUsedINTEGER记录消耗的积分 (20)
metadataTEXTJSON 字符串,存储生成参数 (size, watermark 等)

Metadata 示例:

{
  "size": "2K",
  "watermark": false,
  "imageUrl": "https://example.com/reference.jpg"
}

API 实现

API 端点

路由: POST /api/image/generate

实现文件: app/api/image/generate/route.ts

请求格式

// POST /api/image/generate
{
  "prompt": "将这张照片转换为水彩画风格",
  "imageUrl": "https://example.com/reference.jpg",
  "size": "2K",           // 可选, 默认 'adaptive'
  "watermark": false      // 可选, 默认 true
}

参数说明:

参数类型必需说明
promptstring图像编辑的描述 (如 "转换为水彩画风格")
imageUrlstring参考图像的 URL (必须是可访问的公开 URL)
sizestring输出分辨率 (adaptive / 1K / 2K / 4K)
watermarkboolean是否添加水印,默认 true

响应格式

成功响应 (200)

{
  "id": "uuid-xxx-xxx",
  "url": "https://your-r2-storage.com/images/user_123/image_xxx.png",
  "revisedPrompt": "将这张照片转换为水彩画风格",
  "remainingCredits": 280,
  "sourceImageUrl": "https://example.com/reference.jpg"
}

字段说明:

字段类型说明
idstring生成记录 ID,可用于查询历史
urlstring生成图像的 URL (存储在 R2)
revisedPromptstringAPI 优化后的提示词 (可能与输入略有不同)
remainingCreditsnumber扣费后用户剩余积分
sourceImageUrlstring输入的参考图像 URL

错误响应

// 401 - 未登录
{
  "error": "Unauthorized"
}

// 400 - 缺少必需参数
{
  "error": "Prompt is required"
}

// 400 - 缺少参考图像
{
  "error": "Reference image is required"
}

// 402 - 积分不足
{
  "error": "Insufficient credits",
  "creditsNeeded": 20,
  "remainingCredits": 5
}

// 500 - 生成失败
{
  "error": "Failed to generate image"
}

核心实现逻辑

步骤 1: 身份认证与权限检查

// 1. 验证用户登录状态
const session = await auth.api.getSession({ headers: req.headers });
if (!session?.session?.userId) {
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const userId = session.session.userId;

步骤 2: 解析请求并验证

// 2. 解析请求体
const { prompt, size, watermark, imageUrl } = await req.json();

// 3. 验证必需参数
if (!prompt) {
  return NextResponse.json({ error: "Prompt is required" }, { status: 400 });
}

if (!imageUrl) {
  return NextResponse.json({ error: "Reference image is required" }, { status: 400 });
}

重要: 图生图模式必须提供参考图像,否则无法生成。

步骤 3: 检查积分余额

// 4. 检查用户是否有足够积分 (20 积分)
const creditsNeeded = 20;
const hasCredits = await canUserAfford(userId, creditsNeeded);
if (!hasCredits) {
  return NextResponse.json({
    error: "Insufficient credits",
    creditsNeeded,
    remainingCredits: 0
  }, { status: 402 });
}

步骤 4: 创建生成记录

// 5. 创建生成历史记录
const historyId = randomUUID();
await db.insert(generationHistory).values({
  id: historyId,
  userId,
  type: "image",
  prompt,
  status: "processing",
  creditsUsed: creditsNeeded,
  metadata: JSON.stringify({ size, watermark, imageUrl }),
});

为什么先创建记录?

  • 即使后续步骤失败,也能追踪尝试记录
  • 提供关联 ID 用于积分账本记录
  • 便于管理员排查问题

步骤 5: 扣除积分

// 6. 扣除积分 (事务性操作)
const deductResult = await deductCredits(
  userId,
  creditsNeeded,
  "image_generation",
  historyId
);

if (!deductResult.success) {
  // 更新记录为失败状态
  await db.update(generationHistory)
    .set({ status: "failed", error: deductResult.error })
    .where(eq(generationHistory.id, historyId));

  return NextResponse.json({
    error: deductResult.error || "Failed to deduct credits",
    remainingCredits: deductResult.remainingCredits
  }, { status: 402 });
}

步骤 6: 调用火山引擎 API

try {
  // 7. 生成图像
  const result = await volcanoEngine.generateImage(prompt, {
    size,
    inputImages: [imageUrl],  // 传入参考图像
    watermark,
  });

  if (!result.data || result.data.length === 0) {
    throw new Error('No image generated');
  }

  const imageData = result.data[0];
  // imageData.url: 火山引擎返回的图像 URL
  // imageData.revised_prompt: 优化后的提示词

步骤 7: 上传到 R2 存储

  // 8. 上传图像到 R2 存储 (长期保存)
  const r2Url = await uploadImageFromUrl(imageData.url, userId, 'image');

为什么要上传到 R2?

  • 火山引擎返回的 URL 有时效性 (通常 24 小时)
  • R2 存储提供长期、稳定的访问
  • 降低依赖外部服务的风险

步骤 8: 更新生成记录

  // 9. 更新记录为完成状态
  await db.update(generationHistory)
    .set({
      status: "completed",
      resultUrl: r2Url,
      updatedAt: new Date(),
    })
    .where(eq(generationHistory.id, historyId));

  // 10. 返回结果
  return NextResponse.json({
    id: historyId,
    url: r2Url,
    revisedPrompt: imageData.revised_prompt,
    remainingCredits: deductResult.remainingCredits,
    sourceImageUrl: imageUrl,
  });

} catch (genError: any) {
  // 11. 失败处理: 更新记录状态
  await db.update(generationHistory)
    .set({
      status: "failed",
      error: genError.message,
      updatedAt: new Date(),
    })
    .where(eq(generationHistory.id, historyId));

  throw genError;
}

注意: 当前实现不会自动退还积分,即使生成失败。如果需要退款机制,可以添加:

catch (genError: any) {
  // 退还积分
  await refundCredits(userId, creditsNeeded, "refund", historyId);

  // 更新记录...
}

积分扣费机制

扣费规则

  • 消耗标准: 每次图像生成消耗 20 积分
  • 扣费时机: 在调用 API 之前扣除,防止积分不足时浪费 API 调用
  • 失败退款: 当前不会自动退款 (可根据需求自行添加)

积分消耗定义

定义位置: app/api/image/generate/route.ts:36

const creditsNeeded = 20; // 图像生成消耗 20 积分

调整消耗量:

// 根据分辨率动态定价
const creditsCost = {
  'adaptive': 20,
  '1K': 15,
  '2K': 25,
  '4K': 40,
};

const creditsNeeded = creditsCost[size] || 20;

客户端集成示例

前端实现 (React)

import { useState } from "react";

export function ImageGeneratorComponent() {
  const [prompt, setPrompt] = useState("");
  const [imageUrl, setImageUrl] = useState("");
  const [resultUrl, setResultUrl] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [credits, setCredits] = useState<number>(0);

  const handleGenerate = async () => {
    if (!prompt || !imageUrl) {
      alert("请输入提示词和参考图像 URL");
      return;
    }

    setIsLoading(true);
    setResultUrl(null);

    try {
      const response = await fetch("/api/image/generate", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          prompt,
          imageUrl,
          size: "2K",
          watermark: false,
        }),
      });

      if (!response.ok) {
        const error = await response.json();

        if (response.status === 402) {
          alert(`积分不足!需要 ${error.creditsNeeded} 积分,当前余额 ${error.remainingCredits}`);
          return;
        }

        throw new Error(error.error || "Failed to generate image");
      }

      const result = await response.json();
      setResultUrl(result.url);
      setCredits(result.remainingCredits);

      console.log("生成记录 ID:", result.id);
      console.log("优化后的提示词:", result.revisedPrompt);

    } catch (error: any) {
      console.error("Image generation error:", error);
      alert(error.message);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      {/* 积分显示 */}
      <div>剩余积分: {credits}</div>

      {/* 输入区域 */}
      <div>
        <label>参考图像 URL:</label>
        <input
          type="text"
          value={imageUrl}
          onChange={(e) => setImageUrl(e.target.value)}
          placeholder="https://example.com/image.jpg"
          disabled={isLoading}
        />
      </div>

      <div>
        <label>提示词:</label>
        <textarea
          value={prompt}
          onChange={(e) => setPrompt(e.target.value)}
          placeholder="将这张照片转换为水彩画风格"
          disabled={isLoading}
        />
      </div>

      <button onClick={handleGenerate} disabled={isLoading || !prompt || !imageUrl}>
        {isLoading ? "生成中..." : "生成图像 (20 积分)"}
      </button>

      {/* 结果显示 */}
      {resultUrl && (
        <div>
          <h3>生成结果:</h3>
          <img src={resultUrl} alt="Generated" style={{ maxWidth: "100%" }} />
          <a href={resultUrl} download>下载图像</a>
        </div>
      )}
    </div>
  );
}

带文件上传的实现

如果需要用户上传本地图像,需要先上传到服务器获取 URL:

const handleFileUpload = async (file: File) => {
  const formData = new FormData();
  formData.append("file", file);

  const response = await fetch("/api/upload", {
    method: "POST",
    body: formData,
  });

  const { url } = await response.json();
  setImageUrl(url);
};

Fetch API 示例 (纯 JavaScript)

async function generateImage(prompt, imageUrl, options = {}) {
  const response = await fetch('/api/image/generate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      prompt,
      imageUrl,
      size: options.size || '2K',
      watermark: options.watermark !== undefined ? options.watermark : false,
    }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error);
  }

  return response.json();
}

// 使用示例
generateImage(
  '将这张照片转换为水彩画风格',
  'https://example.com/photo.jpg',
  { size: '2K', watermark: false }
)
  .then(result => {
    console.log('生成成功!');
    console.log('结果 URL:', result.url);
    console.log('剩余积分:', result.remainingCredits);
  })
  .catch(error => console.error('生成失败:', error.message));

常见问题处理

1. 参考图像格式要求

支持的格式: JPEG, PNG, WebP

建议:

  • 图像大小: 不超过 10MB
  • 分辨率: 建议在 512x512 到 2048x2048 之间
  • 确保图像 URL 可公开访问 (不需要认证)

2. 提示词编写技巧

有效的提示词示例:

✅ "将这张照片转换为水彩画风格,保留人物特征"
✅ "在图像中添加夕阳光效,色调偏暖"
✅ "移除背景,替换为纯白色"
✅ "将照片风格改为赛博朋克,增强霓虹灯效果"

无效的提示词:

❌ "好看" (太模糊)
❌ "修改" (没有明确指示)
❌ "画一只猫" (应该使用文生图,而非图生图)

3. 生成失败的常见原因

错误信息可能原因解决方案
"Reference image is required"未提供参考图像 URL确保请求中包含 imageUrl 字段
"No image generated"API 返回空结果检查提示词是否有效,稍后重试
"Failed to upload to R2"R2 存储配置错误检查 STORAGE_* 环境变量
"Insufficient credits"积分不足提示用户购买积分

高级功能

批量生成

async function batchGenerateImages(prompts: string[], imageUrl: string) {
  const results = [];

  for (const prompt of prompts) {
    const result = await fetch('/api/image/generate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ prompt, imageUrl }),
    }).then(r => r.json());

    results.push(result);

    // 添加延迟避免频繁调用
    await new Promise(resolve => setTimeout(resolve, 1000));
  }

  return results;
}

// 使用
const prompts = [
  '转换为水彩画风格',
  '转换为油画风格',
  '转换为素描风格',
];

batchGenerateImages(prompts, 'https://example.com/photo.jpg')
  .then(results => console.log('批量生成完成:', results));

查询生成历史

// API 端点 (需要自己实现)
// GET /api/image/history?limit=10

async function getImageHistory(limit = 10) {
  const response = await fetch(`/api/image/history?limit=${limit}`);
  return response.json();
}

// 返回格式
/*
[
  {
    id: "uuid-xxx",
    prompt: "转换为水彩画风格",
    imageUrl: "https://...",
    resultUrl: "https://...",
    status: "completed",
    createdAt: "2025-10-15T10:30:00Z"
  },
  ...
]
*/

动态定价

根据分辨率调整积分消耗:

// app/api/image/generate/route.ts
const { prompt, imageUrl, size = 'adaptive', watermark } = await req.json();

// 根据分辨率计算积分
const creditsCostMap = {
  'adaptive': 20,
  '1K': 15,
  '2K': 25,
  '4K': 40,
};

const creditsNeeded = creditsCostMap[size] || 20;

// 检查余额
const hasCredits = await canUserAfford(userId, creditsNeeded);
// ... 后续逻辑

性能优化建议

1. 图像 URL 预加载

前端先预加载图像,确保 URL 有效:

const preloadImage = (url: string): Promise<void> => {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve();
    img.onerror = () => reject(new Error('Failed to load image'));
    img.src = url;
  });
};

// 在提交前验证
await preloadImage(imageUrl);

2. R2 上传并行化

如果生成多张图像,可以并行上传:

const uploadPromises = imageUrls.map(url =>
  uploadImageFromUrl(url, userId, 'image')
);

const r2Urls = await Promise.all(uploadPromises);

3. 缓存火山引擎 Token

如果使用自定义认证,可以缓存 Token 减少请求:

let cachedToken: string | null = null;
let tokenExpiry: number = 0;

async function getVolcanoToken() {
  if (cachedToken && Date.now() < tokenExpiry) {
    return cachedToken;
  }

  // 获取新 Token
  const response = await fetch('https://api.volcengine.com/auth/token', { ... });
  const { token, expires_in } = await response.json();

  cachedToken = token;
  tokenExpiry = Date.now() + expires_in * 1000;

  return token;
}

相关文档

总结

AI 图像生成功能通过以下关键设计提供稳定的服务:

  1. 图生图模式: 基于参考图像进行编辑,保留原始特征
  2. 长期存储: 自动上传到 R2,避免临时 URL 失效
  3. 事务性扣费: 确保积分扣除和记录的一致性
  4. 完整历史: 记录每次生成的参数和结果,便于追溯

按照本文档的指导,你可以快速集成图像生成功能并根据业务需求进行定制。