- 作者:老汪软件技巧
- 发表时间: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 应用前端开发技能库。