• 作者:老汪软件技巧
  • 发表时间:2024-11-08 00:02
  • 浏览量:

需求 & 效果

AIGC 的一个很常见的需求,就是用户给出文案内容然后模型生成对应的推广配图。这篇文章就来讲一下前端方面如何实现这个需求。先来看一下效果(仅作为技术分享,隐私信息已做脱敏处理):

需求很简单,客户想给产品做推广,能不能提供产品的信息之后自动就生成对应的宣传封面图(例如某音某红书)和宣传文案。

其实配图一开始也是希望用 AI 生成的,但是目前 AI 直出图的效果大家也都知道,味太浓了。并且生成图里的产品细节也不好控制,比如我要做一个洗衣液的配图,结果生成出来的洗衣液里带竞品的 LOGO,这就很尴尬了。所以沟通之后还是让客户市场部的人先把图搞好一起传上去,然后再让 AI 拼在一起。

实现思路

其实这个功能里分两部分:

可以看出来这个需求对模型的要求并不是很高,需要注意的就是:不要让模型直接生成样式配置和位置信息,而是前端给出几个模板(例如标题、副标题、正文),然后在提示词里描述这几个模板的内容应该是什么,位置在哪里,适合多长的内容。然后让模型给出模板类型。这样的话输出的稳定性就更好一点。

剩下的就是本文的重中之重了:前端如何实现在线编辑功能。

前端实现图片在线编辑

目前市面上有 canvas 编辑功能的包并不少,比如 konva、paper、roughjs、fabricjs,更大一点的比如 polotno、pixijs。

最终权衡之下选择了 fabricjs,一方面 fabricjs 内建了在线编辑的功能,不像 konva 只是个编辑器内核。又不像 polotno 那样又大又全,毕竟我们这个需求也不需要太复杂的编辑页面,自己实现一下就可以了。

受限于篇幅,我这里就挑和 fabricjs 相关的核心代码来讲解一下,至于右边的编辑面板其实就是一个 antd 的 form 表单,我就略过不提了。

1、fabricjs 初始化

第一步是初始化 fabric 实例,这个比较简单,创建好后把当前选中的文本标签属性关联到右侧的 form 上即可:

// 依赖
import { fabric } from 'fabric';
import { Form } from 'antd';

// 核心代码
const canvasEl = useRef<HTMLCanvasElement>(null);
const [form] = Form.useForm();
const [selectedFabricElement, setSelectedFabricElement] = useState<any>();
useEffect(() => {
  const canvas = new fabric.Canvas(canvasEl.current, {
    // backgroundColor: 'rgb(100,100,200)',
  });
  fabricCanvasRef.current = canvas;
  canvas.on('mouse:down', () => {
    const activeObject = canvas.getActiveObject
    setSelectedFabricElement(activeObject);
    if (!activeObject) {
      form.resetFields();
      setSelectedFabricElement(null);
      return;
    }
    form.setFieldsValue({
      fontFamily: activeObject.fontFamily,
      fontSize: activeObject.fontSize,
      lineHeight: activeObject.lineHeight,
      strokeWidth: activeObject.strokeWidth,
      textBackgroundColor: activeObject.textBackgroundColor,
      stroke: activeObject.stroke,
      fill: activeObject.fill,
      textAlign: activeObject.textAlign,
      fontWeight: activeObject.fontWeight === 'bold',
      fontStyle: activeObject.fontStyle === 'italic',
      underline: activeObject.underline,
      linethrough: activeObject.linethrough,
    });
  });
  canvas.on('object:modified', debounceOnChange);
  canvas.on('object:added', debounceOnChange);
  canvas.on('object:removed', debounceOnChange);
  return () => {
    canvas.dispose();
  };
}, []);

注意其中会在点击的时候把当前选中的标签绑定到一个 react 状态 electedFabricElement 上,这样可以在选中之后触发一些 react UI 的渲染。

你可能注意到了其中给很多事件都绑定了一个 debounceOnChange 方法,它的作用是在用户编辑的时候,把修改后的图片实时同步到左侧的预览列表里,实现也很简单,调一下 canvas 的图片导出即可:

// 依赖
import { useDebounceFn } from 'ahooks';

const onChange = () => {
  const canvas = fabricCanvasRef.current;
  if (!canvas) return;
  const dataUrl = canvas.toDataURL('image/png');
  const newValues = {
    ...props.defaultValue,
    preview: dataUrl,
    selectedLabels: canvas.getObjects(),
  };
  props.onChange?.(newValues);
};
const { run: debounceOnChange } = useDebounceFn(onChange, { wait: 500 });

2、canvas 尺寸自适应

这里需要注意的是要做屏幕尺寸的自适应,因为不同用户的电脑屏幕肯定是不同的,所以这里要动态计算一下:

_前端如何实现封装_前端封装是什么意思

const canvasContainerRef = useRef(null);
const containerSize = useSize(canvasContainerRef);
const canvasSize = useMemo(() => {
  // 根据容器计算实际画布大小,尺寸为 414 * 896
  if (!containerSize) return {};
  const height = containerSize?.height - 10;
  const ratio = 700 / 400;
  const canvasWidth = Math.floor(height / ratio);
  const canvasHeight = height;
  return { width: canvasWidth, height: canvasHeight };
}, [containerSize]);

我这里是设置的固定 700*400 的尺寸,你也可以在这里设置不同的分辨率选项。对应的 jsx 如下:

<canvas ref={canvasEl} height={canvasSize.height} width={canvasSize.width} />

然后将这个尺寸设置到 fabric 实例即可:

useEffect(() => {
  if (!canvasSize || !fabricCanvasRef.current) return;
  fabricCanvasRef.current.setWidth(canvasSize.width);
  fabricCanvasRef.current.setHeight(canvasSize.height);
  fabricCanvasRef.current.renderAll();
  initImage();
}, [canvasSize.width, canvasSize.height]);

其中调用了一个 initImage 函数,这个函数实际上是把背景的图片和标签渲染上来,因为尺寸变化了,里边的内容就要跟着重新渲染一下,不然位置会错位:

const initImage = async () => {
  const canvas = fabricCanvasRef.current;
  if (!canvas) return;
  canvas.clear();
  const { bgImage, selectedLabels } = props.defaultValue;
  const bgUrl = URL.createObjectURL(bgImage);
  const img = await createFabriceImage(bgUrl);
  img.scaleToWidth(canvasSize.width);
  img.scaleToHeight(canvasSize.height);
  await setCanvasBackgroundImage(fabricCanvasRef.current, img, {
    top: 0,
    left: 0,
    originX: 'left',
    originY: 'top',
  });
  console.log('selectedLabels', selectedLabels);
  selectedLabels.forEach((label) => {
    canvas.add(label);
  });
  canvas.renderAll();
};

里边用到了两个 util 函数,就是简单的把 api 包装成 async 了:

// 依赖
import { fabric } from 'fabric';
export const createFabriceImage = async (url: string) => {
  return new PromiseImage>((resolve) => {
    fabric.Image.fromURL(url, resolve);
  });
};
export const setCanvasBackgroundImage = (
  canvas: fabric.Canvas,
  image: fabric.Image,
  config: any,
) => {
  return new Promise<void>((resolve) => {
    canvas.setBackgroundImage(
      image,
      () => {
        canvas.renderAll();
        resolve();
      },
      config,
    );
  });
};

3、添加标签

这个就很简单了,调 fabric 的 api 就行:

const onAddLabel = (labelName: string) => {
  if (!fabricCanvasRef.current) return;
  const text = new fabric.Textbox(labelName, {
    left: 100,
    top: 100,
    width: 200,
    height: 100,
    textAlign: 'center',
  });
  fabricCanvasRef.current.add(text);
  fabricCanvasRef.current.setActiveObject(text);
};

4、删除标签

这个也是调 api 就行,我们上面也把当前选中的标签存到了状态里,直接删掉即可:

const onClearSelected = () => {
  if (!fabricCanvasRef.current) return;
  setSelectedFabricElement((prev) => {
    if (prev) fabricCanvasRef.current.remove(prev);
    return null;
  });
};
// 按 delete 键时删除所选
useEffect(() => {
  const onKeyDown = (e) => {
    if (e.key === 'Delete') {
      onClearSelected();
    }
  };
  window.addEventListener('keydown', onKeyDown);
  return () => {
    window.removeEventListener('keydown', onKeyDown);
  };
}, []);

但是要注意,调用 setSelectedFabricElement 清空时要用回调形式拿最新的值。不然 window.addEventListener('keydown', onKeyDown); 的时候就闭包保存了最老的一个 selectedFabricElement 了。

5、编辑标签样式

想实现右侧标签表单编辑的时候样式跟着更新的效果,只需要给右侧的 Form 组件的 onValuesChange 绑定下面的回调即可:

const getAntdColorValue = (color) => {
  if (!color) return undefined;
  if (typeof color === 'string') return color;
  return color.toHexString();
};
const onValuesChange = (values, allValues) => {
  if (!selectedFabricElement) return;
  const { textBackgroundColor, stroke, fill, fontWeight, fontStyle, ...otherValues } = allValues;
  const newConfig = { ...otherValues };
  newConfig.textBackgroundColor = getAntdColorValue(textBackgroundColor);
  newConfig.stroke = getAntdColorValue(stroke);
  newConfig.fill = getAntdColorValue(fill);
  newConfig.fontWeight = fontWeight ? 'bold' : 'normal';
  newConfig.fontStyle = fontStyle ? 'italic' : 'normal';
  selectedFabricElement.set(newConfig);
  selectedFabricElement.canvas.renderAll();
  debounceOnChange();
};

到这里基础的增删改查就实现的差不多了,其实最复杂的逻辑就是canvas 容器、canvas、fabric、要展示的图片这几个的尺寸换算。除此之外比较大的工作量也就是写一个右侧的配置表单了。如果你有导出需求的话可以用 jszip - npm。用法也很简单,直接调 api 保存 base64 数据即可:

import JSZip from 'jszip';
const onExport = async (images: string[]) => {
  const zip = new JSZip();
  images.forEach((item, index) => {
    const base64Data = item.startsWith('data:image/png;base64,')
      ? item.split('base64,')[1]
      : item;
    zip.file(`图片${index}.png`, base64Data, { base64: true });
  });
  const content = await zip.generateAsync({ type: 'blob' });
  const file = new File([content], '导出压缩包.zip', {
    type: 'application/zip',
  });
  downloadFile(file);
};
export function downloadFile(file: File) {
  const url = URL.createObjectURL(file);
  const link = document.createElement('a');
  link.href = url;
  link.download = file.name;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
}

至此功能的核心实现就基本完成了,如果你对前端 AI 领域的其他开发技能感兴趣,可以来看一下我写的相关系列文章 AI 应用前端开发技能库。