AI数字人名片编辑器前端实现

101 阅读10分钟

今天同步下最近在做的一个AI项目技术笔记: AI数字人名片编辑器。

国庆中秋双节期间注册就送 2000在信豆。
在线体验地址:xn--gmqy7wt0j.zaixin.com/main/home

image.png

项目遵循了现代前端开发中的数据驱动单一数据源 (Single Source of Truth) 原则。所有的元素位置、尺寸(包括数字人形象、Logo、文本等)在 Pinia Store (aiCard.js) 中都以原始模板尺寸的像素值进行存储。

核心逻辑流程如下:

  1. 数据初始化: 后端返回的 templateData 包含所有元素的原始尺寸和位置信息。
  2. 基准确立: 底图加载完成后,捕获其原始尺寸 (originalWidth, originalHeight) 和当前的显示尺寸 (backgroundWidth, backgroundHeight)。
  3. 计算缩放: 通过 scaleRatio Getter 计算出显示尺寸与原始尺寸的比例。
  4. 视图渲染: 所有元素的样式在渲染时,都通过 getStyleValue Action,利用 scaleRatio 将原始值动态转换为屏幕显示值。
// aiCard.js 中的 scaleRatio Getter
scaleRatio: (state) => {
    if (state.originalHeight === 0) return 1
    // 采用高度比作为基准缩放比,确保元素与底图同步缩放
    return state.backgroundHeight / state.originalHeight;
}

// aiCard.js 中的 getStyleValue Action
getStyleValue(prop, value, ratio) {
    const numericValue = parseFloat(value);
    // 只有带px单位的属性才进行缩放
    if (isNaN(numericValue) || prop.indexOf('px') === -1) {
        return value;
    }
    // value 为原始值,ratio 为 scaleRatio
    return numericValue * ratio;
}

这种设计保证了用户在编辑器中对元素进行的操作(拖拽、缩放)实际上修改的是 Store 中的原始值,而视图只负责将这些原始值按比例显示,完美实现了数据与视图的解耦。


二、固定底图尺寸和缩放比:基准的建立

要使所有元素都以底图为基准进行缩放,首先必须固定底图的显示尺寸和比例

1. 底图样式的关键实现

editContainer.vue 中,底图的父容器 .image-wrapper 负责决定最大可用空间,而底图自身通过以下 CSS 确保自动适应父容器并保持比例:

/* editContainer.vue style scoped */
.image-wrapper {
    position: relative;
    overflow: hidden;
    height: 100%; /* 父容器高度占满 */
    /* ... 其他样式 */
}

.background {
    height: 100%; /* 图片高度自动撑满父容器 image-wrapper 的高度 */
    display: block;
    width: auto; /* 宽度按比例自动调整 */
    position: relative;
    z-index: 11;
}

通过设置底图 <img class="background" />height: 100%;width: auto;,底图会垂直撑满编辑容器的可用空间,并且宽度自动等比例缩放。这样,底图的显示尺寸就确定了,成为了所有元素缩放的基准。底图加载完成后,handleImageLoad 记录下其 naturalWidth/Height(原始尺寸)和 backgroundWidth/Height(显示尺寸),为 scaleRatio 的计算提供了基础。

2. 精确计算图片比例的核心函数

为了精确地处理图片的等比例缩放,我们需要知道其最简整数比。这通过 getImageAspectRatiogetGCD 两个工具函数实现:

// aiCard.js 中的 getGCD
getGCD(a, b) {
    // 递归计算最大公约数 (Greatest Common Divisor)
    return b === 0 ? a : getGCD(b, a % b);
}

// aiCard.js 中的 getImageAspectRatio
function getImageAspectRatio(url) {
    // ... 省略加载图片和获取 naturalWidth/Height 逻辑
    // 计算最大公约数
    const gcd = getGCD(width, height);

    // 计算最简整数比
    const wRatio = width / gcd; // 宽度比
    const hRatio = height / gcd; // 高度比

    resolve([wRatio, hRatio]);
}

为什么要计算最大公约数 (GCD)?

getGCD 的作用是将图片真实的像素尺寸(例如 1920x1080)简化为最简整数比(例如 16:9)。使用最简整数比进行后续的尺寸计算,可以确保在进行复杂的缩放和尺寸计算时,使用最简洁、最精确的比例值,避免因浮点数累积误差导致的微小偏差,确保等比例计算的绝对准确性


三、数字人形象的底层置入与位置大小操控

1. 底层置入与叠放次序

数字人形象 (humanImage.vue 用于显示,humanEditor.vue 用于编辑) 被放置在底图 <img class="background"> 的附近,且都处于 .image-wrapper 容器中。

  • 底图editContainer.vue 中设置 z-index: 11;
  • 数字人编辑器humanEditor.vue 中设置 position: absolute;z-index: 111;

这种设计使得底图作为画布的底层,而数字人编辑器则绝对定位并覆盖在底图之上,且具有更高的 z-index,从而可以接收鼠标事件进行拖拽和缩放操作。human-editorcursor: move; 也明确了其可被直接操控的特性。

2. 操控与数据同步

用户在 humanEditor.vue 中进行拖拽和缩放操作时:

  • handleDragStarthandleResizeStart 捕获鼠标事件。
  • handleMouseMovehandleResize 实时计算新的位置 (left, top) 和尺寸 (width, height)。
  • 关键在于,humanEditor.vue 计算出的新值是基于当前显示尺寸的像素值,但它会将其逆向转换回原始模板尺寸的值,并更新到 Pinia Store 的 humanData 中。
  • 随后,humanImage.vue 中的 watch 会监测到 Store 的变化,并重新计算缩放后的 AICardStore.humanStyle,并将样式应用到 humanEditor.vue 上,完成闭环。

四、保证任意形象在操控情况下不变形

不变形(即保持图片的固有宽高比)是数字人形象编辑的核心要求。这一机制主要在 humanImage.vuewatch 逻辑中实现。

1. 核心逻辑

humanImage.vue 通过监听 AICardStore.humanData 的变化,执行以下逻辑:

  1. 获取数字人图片的最简宽高比 ratio (e.g., [wRatio, hRatio]),这来自于 getImageAspectRatio
  2. 获取 Store 中存储的原始尺寸props.style.width, props.style.height)。
  3. 计算显示尺寸:先按 scaleRatio 计算出其中一个维度的显示值(例如 width)。

2. 严格的比例保持

为了不变形,另一维度(height)不会直接使用原始值缩放后的结果,而是利用数字人图片的固有比例 ratio 来重新计算

// humanImage.vue watch 逻辑片段
// ... 获取比例 ratio [wRatio, hRatio]

// 无论用户如何拖拽 humanEditor,这里都会强制重新计算 height 以保持比例
width = AICardStore.getStyleValue('width', props.style.width, props.scaleRatio) 
// 注:这里应根据实际需求决定以哪个尺寸为基准。
// 假设以宽度为基准计算高度,确保比例:
height = `${width * (ratio[1] / ratio[0])}`; // 高度 = 宽度 * (hRatio / wRatio)

// ... 更新 AICardStore.humanStyle

在实际代码片段中,虽然存在一个 isVertical 的判断来决定以哪个方向(宽或高)的缩放作为基准,但核心思想始终是:

当数字人编辑器的尺寸发生变化时,我们总是用数字人形象自身的原始比例 ratio 来强制校正另一个维度,从而抵消用户在编辑框中可能引入的形变,确保数字人形象在任何操控下都能以正确的宽高比显示。


五、导出时恢复原尺寸的策略与技术

导出的目标是获取一个原始分辨率的图像文件,而非一个缩放后的屏幕截图。

1. 恢复原始尺寸的原理

如前所述,Store 中存储的都是原始尺寸值。导出的核心步骤是:

  1. 临时切换渲染尺寸: 在导出前,将包裹所有元素的 .image-wrapper 容器的尺寸(宽度和高度)临时设置为 AICardStore.originalWidth AICardStore.originalHeight
  2. 重置缩放比: 强制将 scaleRatio 的计算结果视为 1(或者将用于导出的 scaleRatio 参数设置为 1)。
  3. 原始渲染: 此时,所有元素样式通过 getStyleValue(..., 1) 计算出的值就是其原始像素值,整个卡片将在屏幕上以其原始分辨率进行渲染。

2. getCardImageFile 的技术要点(推断)

虽然 getCardImageFile 方法体未提供,但根据要求,其技术路线应为:DOM → SVG → Blob。

  • DOM → SVG/Canvas 转换: 通常利用 html2canvasdom-to-image 等库将渲染完成的 DOM 结构(即恢复原始尺寸后的 .image-wrapper 元素)转换为 Canvas 或 SVG 格式。
  • 为何要先转为 SVG 再转为 Blob (或 Canvas - > Blob):
    • SVG 优势: SVG 是一种基于 XML 的矢量图形格式,尤其适合处理文本和几何图形,能保证导出的清晰度和精确度。
    • Blob 是最终文件格式: Blob (Binary Large Object) 是一种存储二进制数据的对象。无论是将 SVG 或 Canvas 转换为图像文件(如 PNG/JPEG),最终都需要通过 toBlob() 或类似方法将其封装成 Blob 对象,才能进行下载或上传操作。

3. 设置 pixelRatio=1 的必要性

在调用 html2canvasdom-to-image 这类导出库时,必须设置 pixelRatio=1

  • 默认行为: 这些库默认会获取当前设备的像素比window.devicePixelRatio),例如在 Retina 屏上可能为 23
  • 影响: 如果不设置 pixelRatio=1,导出的图片分辨率将会是 原始尺寸 * devicePixelRatio,例如 1920x1080 的模板在 2 倍屏上会导出为 3840x2160 的图片。
  • 目的: 设置 pixelRatio=1 可以阻止导出库使用系统分辨率进行超采样,确保最终导出的文件分辨率精确等于我们设置的原始模板尺寸 (originalWidth x originalHeight),满足了导出文件尺寸的标准化需求。

六、保证数字人始终有一边小于模板尺寸

这个要求是针对数字人形象的最大尺寸约束。这需要在 humanEditor.vuehandleResize 逻辑中实施边界检查和钳制 (Clamping)

实现思路:

  1. 在用户拖拽角点进行缩放时,handleResize 实时计算出新的未缩放的 newWidthnewHeight
  2. 获取模板的原始尺寸 templateWidth (AICardStore.originalWidth) 和 templateHeight (AICardStore.originalHeight)。
  3. 约束判断: 检查数字人新尺寸的两个维度是否同时大于或等于模板的相应维度。
// humanEditor.vue - 假想的 handleResize 逻辑片段
// newOriginalWidth/Height 是计算出的未缩放的新尺寸
const templateWidth = AICardStore.originalWidth;
const templateHeight = AICardStore.originalHeight;

// 1. 获取数字人自身的宽高比 (wRatio, hRatio)
const [wRatio, hRatio] = AICardStore.humanImageRatio;

// 2. 检查是否同时超出了模板的两个维度
// 确保新尺寸不会使得数字人完全覆盖或超出模板
if (newOriginalWidth > templateWidth && newOriginalHeight > templateHeight) {
    // 如果同时超出,则需要根据比例进行钳制
    // 通常是限制以模板的最短边为基准,或直接阻止进一步放大
    
    // 钳制逻辑:限制最大尺寸为模板尺寸,并保持比例
    const maxScaleByWidth = templateWidth / wRatio;
    const maxScaleByHeight = templateHeight / hRatio;

    // 选择一个较小的缩放基准,确保有一边小于模板
    // 例如:当数字人缩放尺寸超过模板任一边时,进行比例缩小
    
    // 此处需要复杂的边界计算确保合规。最简逻辑为:
    if (newOriginalWidth > templateWidth) {
        // 如果宽度超出模板宽度,将宽度限制为模板宽度,高度按比例回算
        newOriginalWidth = templateWidth;
        newOriginalHeight = templateWidth * (hRatio / wRatio);
    } else if (newOriginalHeight > templateHeight) {
        // 如果高度超出模板高度,将高度限制为模板高度,宽度按比例回算
        newOriginalHeight = templateHeight;
        newOriginalWidth = templateHeight * (wRatio / hRatio);
    }
} 

// ... 应用新的 newOriginalWidth/Height 到 Store

通过上述的边界钳制逻辑,数字人的尺寸被严格限制在模板尺寸之内,确保其在保持自身比例的同时,始终有一边(或两边)小于或等于模板的尺寸,从而满足了约束要求。

总结:

这套数字人卡片编辑器代码以 Pinia Store 为核心,通过原始数据存储统一缩放比 (scaleRatio) 机制,优雅地解决了前端编辑器的核心难题:跨分辨率下的高保真实时渲染与高精度原始尺寸导出。从底图的自适应样式,到利用 GCD 保证比例的绝对精确,再到对数字人形象的尺寸钳制,每一个细节都体现了对性能、精度和用户体验的深刻理解。