Memoria-智能影记创新实训博客(五):数字相册功能接口实现与界面展示

博客主题:主线功能的实现
功能时间跨度:2026.04.27 - 2026.05.05(第9-10周)
进度总结:在图片打标、语义搜索和故事生成之后,我继续完成了数字相册书的核心链路。当前功能已经可以从故事结果页进入,把故事段落和照片素材自动排成横向双页相册,并支持模板切换、AI 写文案、元素编辑、图片增删替换、撤回和保存。到这里,故事生成不再只是“生成一段文字”,而是可以继续沉淀成可浏览、可编辑、可二次展示的相册作品。

1. 目标

和前几篇一样,数字相册功能仍然不会把用户相册原图直接上传给云端大模型。当前实现里,数字相册主要接收故事生成阶段已经整理好的结构化素材:故事标题、副标题、每张图对应的文案、标签、OCR 摘要、caption、时间和地点等信息;如果使用 AI 写文案,DeepSeek 处理的也是这些结构化线索和页面文本槽位,而不是原始图片。

数字相册要解决的不是“再生成一篇故事”,而是把故事结果转成一本可以翻阅的相册书:

  • 从故事结果页进入,复用已经生成好的 StorySection
  • 把照片按时间、地点、标签和语义相关性自动分组。
  • 按封面、内容页、结尾页生成横向双页相册。
  • 允许用户继续选择模板、改文字、换图片、换位置、增删元素。
  • 把最终相册作为独立的 AlbumBookDocument 保存到本地数据库。

2. 前期工作

数字相册建立在故事生成之后。故事生成阶段已经把照片整理成 StorySection:每个 section 包含一张图片和一段对应故事文案,图片内部又带有标签、caption、OCR、时间和地点等字段。数字相册不需要重新分析图片,而是把这些素材重新组织成版式。

3. 相册书的数据结构

核心结构是 AlbumBookDocument,它下面有多个 AlbumSpreadModel,每个 spread 表示一个横向双页;每个 spread 又包含左页和右页;每一页由图片、文字、题签、形状等元素组成。

class AlbumBookDocument {
  const AlbumBookDocument({
    required this.title,
    required this.subtitle,
    required this.theme,
    required this.pageWidth,
    required this.pageHeight,
    required this.spreads,
    this.schemaVersion = AlbumBookDesignTokens.schemaVersion,
    this.layoutSource = 'manual',
  });
}

当前相册书使用横向双页设计,单页尺寸是 900 x 860,两个单页组成一个可翻阅的 spread。元素坐标使用归一化坐标,也就是 x / y / w / h 都落在 0.0 - 1.0 范围内。这样做的好处是版式不依赖具体手机屏幕尺寸,页面渲染时只需要按实际画布比例缩放。

相册还定义了一套设计 token,包括纸张底色、墨色、强调色和字体类型。例如 paper_warmpaper_roseink_softrose_accentserif_eleganthandwriting_soft 等。这样本地排版和 AI 文案都不是随意生成,而是被限制在一套统一的视觉语言里。

4. 自动排版:把故事段落变成相册书

数字相册页加载时,会先生成一份本地默认相册,再尝试读取之前保存过的相册。如果本地缓存中的照片集合和当前故事段落不一致,说明故事内容已经变过,系统会删除旧缓存,重新按当前故事生成。

final fallback = _layoutService.buildDefaultBook(
  title: widget.title,
  subtitle: widget.subtitle,
  sections: _workingSections,
  preset: _currentStylePreset,
  allowedTemplateIds: _selectedTemplateIds,
);

loaded = await _bookService.loadByStoryId(widget.storyEntityId!);
if (loaded != null && !_matchesCurrentStorySections(loaded)) {
  await _bookService.deleteByStoryId(widget.storyEntityId!);
  loaded = null;
}

默认排版由 DigitalAlbumLayoutService 完成。它先过滤掉没有图片路径的 section,再按图片 ID 或路径去重;如果没有可用照片,就生成一页留白相册;如果有照片,就用第一张图生成封面,用中间照片生成内容页,用最后一张图生成结尾页。

内容页不是机械地一张图一页,而是会尽量把相关照片放在同一个 spread。相关性的判断主要看四类线索:是否有共同标签、文本语义是否相近、地点是否相同、拍摄时间是否接近。具体来说,两张照片只要满足共同标签、语义相似度不低于 0.28、地点相同、拍摄时间相差不超过 18 小时中的任意一种,就会被认为有关联。

bool _areRelated(StorySection a, StorySection b) {
  if (_sharedTags(a, b) > 0) return true;
  if (_semanticSimilarity(a, b) >= 0.28) return true;
  if (_sameLocation(a.photo.location, b.photo.location)) return true;
  final hours = a.photo.dateTaken.difference(b.photo.dateTaken).inHours.abs();
  return hours <= 18;
}

分组时,系统会优先尝试 4 图、3 图、2 图、1 图的组合。4 图组合要求聚类分数不低于 3.0,3 图组合要求不低于 1.8,2 图组合只要两张图有关联即可。这个策略的目的,是让同一地点、同一时间或同一主题的照片形成一个小段落,而不是被拆散成零碎页面。

当前可选模板一共有 8 种:大图文宣言页双页照三联页拼贴页日记页栏页肖像页。其中大图文、宣言页、栏页、肖像页可以承接单图;双页照适合两张关联图;三联页适合三张连续照片;拼贴页和日记页适合三到四张同主题照片。用户在界面中切换模板后,系统会重新排版,但会尽量把已有文案按角色复用回新页面,避免用户刚改好的文字被直接丢掉。

5. AI 写文案与排版增强

当前数字相册页已经接入并开放“AI 写文案”。它和故事生成不同,不会重新生成长篇故事,也不会调整图片顺序和版式,而是在现有相册版式不变的前提下,只重写每个文本槽位里的标题、正文、题签和装饰短语。

final rewritten = await _aiService.writeCopyForBook(
  title: widget.title,
  subtitle: widget.subtitle,
  sections: currentSections,
  document: document,
  storyTemplateId: widget.storyTemplateId,
);

这一步传给 DeepSeek 的内容主要有两部分:一是照片素材,包括日期、地点、标签、caption、OCR 摘要和故事段落;二是固定页面目标,也就是每个 spread 的模板、左右页包含哪些照片、有哪些文本角色、当前已有文字是什么。提示词明确要求模型不要重新设计、不要移动元素、不要增删页面,只写适合相册排版的短文案。

AI 文案的参数也和故事生成不同:temperature = 0.72topP = 0.92。这里温度比排版规划更高,是因为文案需要一定表达变化;但它仍然被 JSON schema、文本长度和角色约束限制住,不会自由发挥成一篇长文。请求超时时间按照片数量计算,为 90 + 照片数 * 18 秒,并限制在 150 - 420 秒之间,避免大相册等待时间失控。

服务层还保留了 AI 排版能力:照片少时可以要求模型直接返回完整相册 JSON;照片达到 9 张及以上时,会改走 compact plan,只让模型决定照片分组、模板和文案,再由本地 layout service 生成实际坐标。当前界面主链路优先使用本地模板排版,保证不配置 API 时也能完整使用;AI 主要承担“让文字更像一本相册”的增强工作。

6. 校验、编辑与交互

数字相册生成后不会直接信任所有版式数据,而是统一经过 DigitalAlbumValidatorService 清洗。它会检查图片 ID 是否属于当前相册,补齐缺失图片路径,校验颜色和字体 token,把字号限制在 9 - 56,把文本旋转限制在 -25° - 25°,图片旋转固定为 ,并把所有元素约束到页面安全区域内。对于文字元素,它还会根据文本实际高度自动缩放或扩大文本框,尽量避免溢出。

return _validator.sanitize(
  document,
  allowedPhotoIds: allowedPhotoIds,
  photoPathById: photoPathById,
  fallbackTitle: widget.title,
  fallbackSubtitle: widget.subtitle,
);

这一层也会处理图文遮挡问题。数字相册的设计原则是“图片是图片,文字是文字”,正文、标题、题签不能直接压在照片上。如果检测到文字和图片冲突,校验层会尝试把文字移动到纸张留白区域,或者调整文字区域和背景形状,让页面更像一本排版过的相册,而不是几张图随意叠在一起。

界面编辑能力也围绕这套文档结构展开。底部更多菜单提供 AI写文案选择模板保存撤回新增图片新增文案;点击图片后可以更换图片、置于顶层、删除;点击文字后可以直接编辑内容,也可以改字号、颜色、加粗和对齐。用户还可以拖动、缩放元素,所有关键修改都会进入撤回栈,最多保留 40 次历史快照。

翻页体验使用横向 PageController 和自定义翻页动画。只有在没有选中元素、没有进入编辑状态、没有缩放页面、没有正在输入文字时,才允许翻页。拖拽翻页时,如果翻页进度超过 0.34,或者横向速度超过 420,系统就提交翻页,否则回弹。这些限制保证编辑和翻页不会互相干扰。

7. 保存与本地持久化

数字相册不是临时预览。用户点击保存后,系统会把整本相册作为 DigitalAlbumBookEntity 写入 Isar,并用 storyId 做唯一索引。保存的内容不是截图,而是完整 JSON 文档,包括标题、副标题、主题、页面尺寸、spread 数量、layoutSource 和所有页面元素。

entity.storyId = storyId;
entity.title = document.title;
entity.subtitle = document.subtitle;
entity.theme = document.theme;
entity.pageWidth = document.pageWidth;
entity.pageHeight = document.pageHeight;
entity.spreadCount = document.spreads.length;
entity.layoutSource = document.layoutSource;
entity.contentJson = document.encodePretty();

这样做的好处是后续再次打开同一个故事时,可以直接还原用户编辑过的相册,而不是重新生成导致修改丢失。同时,因为保存的是结构化 JSON,之后如果要继续扩展导出、分享、视频联动或数字相册模拟翻书效果,也可以直接复用这份文档。

8. 界面展示

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

9. 总结

当前项目里的数字相册,已经从“故事结果的展示页”升级成了一套完整的相册书编辑系统:它从故事生成结果接收素材,用本地排版服务生成封面、内容页和结尾页,再通过模板选择、AI 写文案、版式校验、元素编辑和本地保存,把照片故事变成可以继续浏览和打磨的数字作品。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐