鸿蒙实战:图片编辑器——文字功能完全实现

鸿蒙实战:图片编辑器------文字功能完全实现(添加/选中/拖拽/编辑/删除/撤回/多行排版)

完整源码ImageEditor

基于 HarmonyOS 5.0+,在涂鸦/马赛克编辑器基础上,从零实现功能完备的文字模块。本文深入讲解独立文字层架构、多行文本精确换行、互斥手势、命中测试、撤回栈等核心难点。

一、功能完整清单

功能模块 具体能力 实现状态
文字添加 弹窗输入文字,设置颜色、大小,确认后添加到图片中心
单击选中 点击文字显示白色边框、四个角点、右上角删除按钮
拖拽移动 按住文字主体可任意拖动,实时更新位置,支持撤回
双击编辑 双击文字弹出编辑弹窗,预填当前内容和样式
删除文字 点击选中框右上角圆形按钮直接删除
实时样式调节 选中文字后,底部工具栏修改颜色/大小立即生效
多行文本 支持手动换行 \n 和自动换行,精确行间距
撤回操作 添加、删除、移动、编辑、样式修改均可撤回
点击空白取消选中 单击非文字区域清除选中
切换工具自动取消选中 切换到涂鸦/马赛克/裁剪时清空文字选中
与涂鸦/马赛克完全隔离 独立文字层,手势互不干扰

运行效果

二、架构设计概览

复制代码
Index (UI层)
  ├─ 管理三个独立配置 (draw/mosaic/text)
  ├─ 监听文字选中/双击回调
  ├─ 控制文字编辑弹窗
  └─ 组合手势 (TapGesture + PanGesture,互斥模式)

EditorManager (代理层)
  └─ 封装 CanvasManager,提供简洁 API

CanvasManager (核心管理层)
  ├─ 命令栈(commands + textUndoStack)
  ├─ 图片渲染 & 裁剪区域管理
  ├─ 手势分流 (文字/涂鸦/马赛克)
  └─ 委托 TextLayerManager

TextLayerManager (独立文字层)
  ├─ 文字命令列表
  ├─ 多行文本排版 (splitLines)
  ├─ 命中测试 (hitTest)
  ├─ 选中/拖拽/双击/删除逻辑
  ├─ 绘制文字 + 选中框
  └─ 回调通知 (选中变化/移动/删除)

三、核心代码实现

3.1 数据模型

javascript 复制代码
export interface TextInfo {
  id: string;
  text: string;
  color: string;
  fontSize: number;
}

// TextCommand.ts
import { TextInfo } from './TextInfo';
export interface TextCommand extends TextInfo {
  type: 'text';
  x: number;
  y: number;
  fontFamily: string;
  rotation: number;   // 预留旋转
  scaleX: number;     // 预留缩放
  scaleY: number;
}

export interface HitTestResult {
  cmd: TextCommand;
  isDelete: boolean;
}

3.2 文字层核心 (TextLayerManager.ets) --- 完整代码

javascript 复制代码
import { TextCommand } from '../model/TextCommand';
import { TextInfo } from '../model/TextInfo';
import { HitTestResult } from '../model/HitTestResult';

interface LineMetrics {
  text: string;
  width: number;
  ascent: number;
  descent: number;
  y: number;
}

interface SplitLinesResult {
  lines: LineMetrics[];
  totalWidth: number;
  totalHeight: number;
}

interface BoundingBox {
  left: number;
  top: number;
  right: number;
  bottom: number;
  totalWidth: number;
  totalHeight: number;
}

export class TextLayerManager {
  public static readonly SELECTION_PADDING_X: number = 8;
  public static readonly SELECTION_PADDING_Y: number = 12;
  private static readonly LINE_SPACING: number = 8;   // px

  private commands: TextCommand[] = [];
  private selectedId: string | null = null;
  private isDragging: boolean = false;
  private dragStartX: number = 0;
  private dragStartY: number = 0;
  private dragStartTextX: number = 0;
  private dragStartTextY: number = 0;
  private dragStartSnapshot: TextCommand | null = null;
  private ctx: CanvasRenderingContext2D;
  private currentWrapWidth: number = 0;

  public onTextSelected?: (info: TextInfo | null) => void;
  public onTextDoubleClick?: (info: TextInfo) => void;
  public onTextDelete?: (id: string) => void;
  public onTextMoved?: (oldCmd: TextCommand) => void;

  constructor(ctx: CanvasRenderingContext2D) {
    this.ctx = ctx;
  }

  public setWrapWidth(width: number): void {
    this.currentWrapWidth = width;
  }

  public setCommands(commands: TextCommand[]): void {
    this.commands = commands;
    if (this.selectedId !== null && !this.commands.some(c => c.id === this.selectedId)) {
      this.selectedId = null;
      this.notifySelected();
    }
  }

  public getSelectedInfo(): TextInfo | null {
    if (!this.selectedId) return null;
    const selected = this.commands.find(c => c.id === this.selectedId);
    return selected ? { id: selected.id, text: selected.text, color: selected.color, fontSize: selected.fontSize } : null;
  }

  public updateTextAttribute(id: string, color?: string, fontSize?: number): void {
    const cmd = this.commands.find(c => c.id === id);
    if (cmd) {
      if (color) cmd.color = color;
      if (fontSize) cmd.fontSize = fontSize;
      this.notifySelected();
    }
  }

  // -------------------- 排版核心 --------------------
  private getWrapWidth(): number {
    return this.currentWrapWidth > 0 ? this.currentWrapWidth : this.ctx.width;
  }

  private splitLines(text: string, wrapWidth: number, fontSize: number, fontFamily: string): SplitLinesResult {
    this.ctx.save();
    this.ctx.font = `${fontSize}px ${fontFamily}`;
    const paragraphs = text.split('\n');
    const allLines: LineMetrics[] = [];

    for (const para of paragraphs) {
      if (para.length === 0) {
        allLines.push({ text: '', width: 0, ascent: 0, descent: 0, y: 0 });
        continue;
      }
      const chars = para.split('');
      let curLine = '';
      let curWidth = 0;
      for (const ch of chars) {
        const w = this.ctx.measureText(ch).width;
        if (curWidth + w <= wrapWidth) {
          curLine += ch;
          curWidth += w;
        } else {
          if (curLine.length) {
            const m = this.ctx.measureText(curLine);
            allLines.push({ text: curLine, width: m.width, ascent: m.actualBoundingBoxAscent, descent: m.actualBoundingBoxDescent, y: 0 });
          }
          curLine = ch;
          curWidth = w;
        }
      }
      if (curLine.length) {
        const m = this.ctx.measureText(curLine);
        allLines.push({ text: curLine, width: m.width, ascent: m.actualBoundingBoxAscent, descent: m.actualBoundingBoxDescent, y: 0 });
      }
    }
    this.ctx.restore();

    let totalWidth = 0;
    for (const l of allLines) if (l.width > totalWidth) totalWidth = l.width;

    let totalHeight = 0;
    let curY = 0;
    for (let i = 0; i < allLines.length; i++) {
      const l = allLines[i];
      const lineHeight = l.ascent + l.descent;
      l.y = curY;
      if (i === allLines.length - 1) {
        totalHeight += lineHeight;
      } else {
        totalHeight += lineHeight + TextLayerManager.LINE_SPACING;
        curY += lineHeight + TextLayerManager.LINE_SPACING;
      }
    }
    return { lines: allLines, totalWidth, totalHeight };
  }

  private getTextBoundingBox(cmd: TextCommand): BoundingBox {
    const wrap = this.getWrapWidth();
    const { totalWidth, totalHeight } = this.splitLines(cmd.text, wrap, cmd.fontSize, cmd.fontFamily);
    const left = cmd.x - totalWidth / 2;
    const top = cmd.y - totalHeight / 2;
    return { left, top, right: left + totalWidth, bottom: top + totalHeight, totalWidth, totalHeight };
  }

  private drawMultilineText(cmd: TextCommand): void {
    const wrap = this.getWrapWidth();
    const { lines, totalWidth, totalHeight } = this.splitLines(cmd.text, wrap, cmd.fontSize, cmd.fontFamily);
    const startX = cmd.x - totalWidth / 2;
    const startY = cmd.y - totalHeight / 2;
    this.ctx.save();
    this.ctx.font = `${cmd.fontSize}px ${cmd.fontFamily}`;
    this.ctx.fillStyle = cmd.color;
    this.ctx.textAlign = 'left';
    this.ctx.textBaseline = 'top';
    for (const line of lines) {
      const x = startX;
      const y = startY + line.y;
      this.ctx.fillText(line.text, x, y);
    }
    this.ctx.restore();
  }

  // -------------------- 命中测试 --------------------
  private hitTest(x: number, y: number): HitTestResult | null {
    for (let i = this.commands.length - 1; i >= 0; i--) {
      const cmd = this.commands[i];
      const bbox = this.getTextBoundingBox(cmd);
      const left = bbox.left - TextLayerManager.SELECTION_PADDING_X;
      const top = bbox.top - TextLayerManager.SELECTION_PADDING_Y;
      const right = bbox.right + TextLayerManager.SELECTION_PADDING_X;
      const bottom = bbox.bottom + TextLayerManager.SELECTION_PADDING_Y;

      // 删除按钮区域 (右上角圆形半径12)
      const delX = right;
      const delY = top;
      const radius = 12;
      const dx = x - delX;
      const dy = y - delY;
      if (dx * dx + dy * dy <= radius * radius) {
        return { cmd, isDelete: true };
      }
      if (x >= left && x <= right && y >= top && y <= bottom) {
        return { cmd, isDelete: false };
      }
    }
    return null;
  }

  // -------------------- 交互事件 --------------------
  // 单击
  public handleStart(x: number, y: number): void {  
    const hit = this.hitTest(x, y);
    if (hit) {
      if (hit.isDelete) {
        this.onTextDelete?.(hit.cmd.id);
        if (this.selectedId === hit.cmd.id) {
          this.selectedId = null;
          this.notifySelected();
        }
        return;
      }
      this.selectedId = hit.cmd.id;
      this.notifySelected();
    } else {
      if (this.selectedId !== null) {
        this.selectedId = null;
        this.notifySelected();
      }
    }
  }
 // 开始拖拽
  public startDrag(x: number, y: number): boolean {
    const hit = this.hitTest(x, y);
    if (hit && !hit.isDelete) {
      this.selectedId = hit.cmd.id;
      this.isDragging = true;
      this.dragStartX = x;
      this.dragStartY = y;
      this.dragStartTextX = hit.cmd.x;
      this.dragStartTextY = hit.cmd.y;
      this.dragStartSnapshot = { ...hit.cmd };
      this.notifySelected();
      return true;
    }
    return false;
  }
  // 拖拽移动
  public handleMove(x: number, y: number): void {  
    if (!this.isDragging || !this.selectedId) return;
    const target = this.commands.find(c => c.id === this.selectedId);
    if (target) {
      target.x = this.dragStartTextX + (x - this.dragStartX);
      target.y = this.dragStartTextY + (y - this.dragStartY);
    }
  }
  // 结束拖拽
  public handleEnd(): void {                      
    if (this.isDragging && this.selectedId && this.dragStartSnapshot) {
      const target = this.commands.find(c => c.id === this.selectedId);
      if (target && (target.x !== this.dragStartSnapshot.x || target.y !== this.dragStartSnapshot.y)) {
        this.onTextMoved?.(this.dragStartSnapshot);
      }
    }
    this.isDragging = false;
    this.dragStartSnapshot = null;
  }

  public handleDoubleClick(x: number, y: number): void {
    const hit = this.hitTest(x, y);
    if (hit && !hit.isDelete) {
      const info: TextInfo = {
        id: hit.cmd.id,
        text: hit.cmd.text,
        color: hit.cmd.color,
        fontSize: hit.cmd.fontSize
      };
      this.onTextDoubleClick?.(info);
    }
  }

  // -------------------- 绘制 --------------------
  public drawAll(): void {
    for (const cmd of this.commands) {
      this.drawMultilineText(cmd);
    }
    if (this.selectedId) {
      const selected = this.commands.find(c => c.id === this.selectedId);
      if (selected) this.drawSelectionBox(selected);
    }
  }

  private drawSelectionBox(cmd: TextCommand): void {
    const bbox = this.getTextBoundingBox(cmd);
    const left = bbox.left - TextLayerManager.SELECTION_PADDING_X;
    const top = bbox.top - TextLayerManager.SELECTION_PADDING_Y;
    const right = bbox.right + TextLayerManager.SELECTION_PADDING_X;
    const bottom = bbox.bottom + TextLayerManager.SELECTION_PADDING_Y + 4; // 底部补偿

    this.ctx.save();
    this.ctx.strokeStyle = '#ffffff';
    this.ctx.lineWidth = 2;
    this.ctx.strokeRect(left, top, right - left, bottom - top);

    // 四个角点
    const pointSize = 8;
    const corners = [[left, top], [right, top], [left, bottom], [right, bottom]];
    this.ctx.fillStyle = '#ffffff';
    for (const p of corners) {
      this.ctx.fillRect(p[0] - pointSize/2, p[1] - pointSize/2, pointSize, pointSize);
    }

    // 删除按钮
    const delX = right;
    const delY = top;
    const radius = 12;
    this.ctx.fillStyle = '#ffffff';
    this.ctx.beginPath();
    this.ctx.arc(delX, delY, radius, 0, 2 * Math.PI);
    this.ctx.fill();
    this.ctx.fillStyle = '#ff3b30';
    this.ctx.font = '42px sans-serif';
    this.ctx.fillText('✕', delX - 4, delY + 4);
    this.ctx.restore();
  }

  // -------------------- 选中管理 --------------------
  public clearSelection(): void {
    if (this.selectedId !== null) {
      this.selectedId = null;
      this.notifySelected();
    }
  }

  private notifySelected(): void {
    this.onTextSelected?.(this.getSelectedInfo());
  }
}

3.3 CanvasManager 文字相关部分

javascript 复制代码
import { image } from '@kit.ImageKit';
import { DrawCommand } from '../model/DrawCommand';
import { DrawStrategy } from '../drawing/DrawStrategy';
import { MosaicStrategy } from '../drawing/MosaicStrategy';
import { CanvasInfo, DrawToolType, DrawRect } from '../model/CanvasInfo';
import { promptAction } from '@kit.ArkUI';
import { TextCommand } from '../model/TextCommand';
import { DrawPoint } from '../model/DrawPoint';
import { TextLayerManager } from './TextLayerManager';
import { TextInfo } from '../model/TextInfo';

type CanvasCommand = DrawCommand | TextCommand;

export class CanvasManager {
  private ctx: CanvasRenderingContext2D;
  private drawStrategy: DrawStrategy;
  private mosaicStrategy: MosaicStrategy;
  private commands: CanvasCommand[] = [];
  private textUndoStack: TextCommand[] = [];   // 文字撤回专用栈
  private currentCommand: DrawCommand | null = null;
  private currentTool: DrawToolType = 'draw';
  private currentColor: string = '#ff0000';
  private currentSize: number = 8;
  private originalImage: image.PixelMap | null = null;
  private canvasInfo: CanvasInfo | null = null;
  private isClipped: boolean = false;
  private textLayerManager: TextLayerManager;

  constructor(ctx: CanvasRenderingContext2D) {
    this.ctx = ctx;
    this.drawStrategy = new DrawStrategy();
    this.mosaicStrategy = new MosaicStrategy();
    this.mosaicStrategy.init(ctx);
    this.textLayerManager = new TextLayerManager(ctx);

    this.textLayerManager.onTextDelete = (id: string) => {
      this.deleteText(id);
    };
    this.textLayerManager.onTextMoved = (oldCmd: TextCommand) => {
      this.pushTextUndoSnapshot(oldCmd);
    };
  }

  // ================== 文字撤回专用 ==================
  private pushTextUndoSnapshot(cmd: TextCommand): void {
    const snapshot: TextCommand = {
      id: cmd.id,
      type: 'text',
      text: cmd.text,
      x: cmd.x,
      y: cmd.y,
      fontSize: cmd.fontSize,
      fontFamily: cmd.fontFamily,
      color: cmd.color,
      rotation: cmd.rotation,
      scaleX: cmd.scaleX,
      scaleY: cmd.scaleY
    };
    this.textUndoStack.push(snapshot);
  }

  private syncTextCommands(): void {
    const textCmds: TextCommand[] = [];
    for (const cmd of this.commands) {
      if (cmd.type === 'text') {
        textCmds.push(cmd as TextCommand);
      }
    }
    this.textLayerManager.setCommands(textCmds);
  }

  // ================== 文字操作完整实现 ==================
  /** 添加文字(会自动记录到 commands 和 textUndoStack?通常新添加不需要立即入撤回栈,但撤回时可通过整体 commands 回退) */
  addText(x: number, y: number, text: string, color: string, fontSize: number): void {
    const cmd: TextCommand = {
      id: Date.now().toString() + Math.random().toString(),
      type: 'text',
      text: text,
      x: x,
      y: y,
      fontSize: fontSize,
      fontFamily: 'HarmonyOS Sans',
      color: color,
      rotation: 0,
      scaleX: 1,
      scaleY: 1
    };
    this.commands.push(cmd);
    // 新添加的文字不主动推入撤回栈(因为撤回时会整体 pop 或者文字栈单独处理)
    // 但如果要支持撤销"添加",应该在 undo 逻辑中处理 commands 的 pop,或者单独推入一个"添加动作"。
    // 为简化,添加时不推入 textUndoStack,而是在 undo 中优先 textUndoStack 为空时走 commands pop。
    this.syncTextCommands();
    this.redrawAll();
  }

  /** 编辑文字(内容、颜色、大小全部替换) */
  updateText(id: string, text: string, color: string, fontSize: number): void {
    const index = this.commands.findIndex(c => c.id === id && c.type === 'text');
    if (index === -1) return;
    const oldCmd = this.commands[index] as TextCommand;
    // 保存旧快照
    this.pushTextUndoSnapshot(oldCmd);
    const newCmd: TextCommand = {
      id: oldCmd.id,
      type: 'text',
      text: text,
      x: oldCmd.x,
      y: oldCmd.y,
      fontSize: fontSize,
      fontFamily: oldCmd.fontFamily,
      color: color,
      rotation: oldCmd.rotation,
      scaleX: oldCmd.scaleX,
      scaleY: oldCmd.scaleY
    };
    this.commands[index] = newCmd;
    this.syncTextCommands();
    this.redrawAll();
  }

  /** 删除文字 */
  deleteText(id: string): void {
    const index = this.commands.findIndex(c => c.id === id && c.type === 'text');
    if (index === -1) return;
    const oldCmd = this.commands[index] as TextCommand;
    this.pushTextUndoSnapshot(oldCmd);
    this.commands.splice(index, 1);
    this.syncTextCommands();
    this.redrawAll();
  }

  /** 仅修改文字属性(颜色或大小) */
  updateTextAttribute(id: string, color?: string, fontSize?: number): void {
    const index = this.commands.findIndex(c => c.id === id && c.type === 'text');
    if (index !== -1) {
      const oldCmd = this.commands[index] as TextCommand;
      this.pushTextUndoSnapshot(oldCmd);
      const newCmd: TextCommand = {
        id: oldCmd.id,
        type: 'text',
        text: oldCmd.text,
        x: oldCmd.x,
        y: oldCmd.y,
        fontSize: (fontSize !== undefined) ? fontSize : oldCmd.fontSize,
        fontFamily: oldCmd.fontFamily,
        color: (color !== undefined) ? color : oldCmd.color,
        rotation: oldCmd.rotation,
        scaleX: oldCmd.scaleX,
        scaleY: oldCmd.scaleY
      };
      this.commands[index] = newCmd;
      this.syncTextCommands();
      this.redrawAll();
    }
  }

  /** 清除当前选中的文字(仅 UI 状态) */
  clearTextSelection(): void {
    this.textLayerManager.clearSelection();
    this.redrawAll();
  }

  // ================== 撤回(集成文字和绘图) ==================
  async undo(): Promise<void> {
    // 1. 优先处理文字的独立撤回栈
    if (this.textUndoStack.length > 0) {
      const snapshot = this.textUndoStack.pop()!;
      const index = this.commands.findIndex(c => c.id === snapshot.id && c.type === 'text');
      if (index !== -1) {
        this.commands[index] = snapshot;
      } else {
        this.commands.push(snapshot);
      }
      this.syncTextCommands();
      await this.redrawAll();
      this.showToast('已撤回');
      return;
    }
    // 2. 涂鸦/马赛克撤回
    if (this.commands.length === 0) {
      this.showToast('没有可撤回的操作');
      return;
    }
    this.commands.pop();
    await this.redrawAll();
    this.showToast('已撤回');
  }

  // ================== 辅助方法 ==================
  private showToast(message: string): void {
    promptAction.showToast({ message: message });
  }

  // 重绘全部(含文字层)
  async redrawAll(): Promise<void> {
    // ... 已有重绘逻辑(重新渲染原图 + 绘制 commands + textLayerManager.drawAll)
    // 这里省略与文字无关部分,实际代码中保持不变
  }

  // 其他与文字无关的方法(如 setOriginalImage, renderOriginalImage 等)省略
}

3.4 Index.ets 手势与选中清除

javascript 复制代码
// Index.ets
@Entry
@Component
struct Index {
  @State selectedTextInfo: TextInfo | null = null;
  private editorManager: EditorManager | null = null;

  aboutToAppear() {
    this.editorManager = new EditorManager(this.context, this.ctx);
    this.editorManager?.setOnTextSelected((info) => {
      this.selectedTextInfo = info;
      // 同步工具条样式...
    });
  }

  // 清除文字选中的统一方法
  private clearTextSelection(): void {
    this.editorManager?.clearTextSelection();
    this.selectedTextInfo = null;
  }

  private onToolChange(tool: DrawToolType): void {
    this.currentTool = tool;
    this.clearTextSelection();   // 任何工具切换都清除选中
    if (tool === 'text') this.showTextEditor = true;
  }

  private async undo(): Promise<void> {
    this.clearTextSelection();
    await this.editorManager?.undo();
  }

  private async clear(): Promise<void> {
    this.clearTextSelection();
    await this.editorManager?.clear();
  }

  build() {
    Canvas(this.ctx)
      .gesture(
        GestureGroup(GestureMode.Exclusive,
          TapGesture({ count: 2 }).onAction((e) => {
            if (this.currentTool === 'text') {
              const { localX, localY } = e.fingerList[0];
              this.editorManager?.handleTextDoubleClick(localX, localY);
            }
          }),
          TapGesture({ count: 1 }).onAction((e) => {
            if (this.currentTool === 'text') {
              const { localX, localY } = e.fingerList[0];
              this.editorManager?.startDraw(localX, localY); // 单击选中
            }
          }),
          PanGesture({ fingers: 1, distance: 5 })
            .onActionStart((e) => {
              if (this.currentTool === 'text') {
                const { localX, localY } = e.fingerList[0];
                this.editorManager?.startDragText(localX, localY);
              } else {
                this.onGestureStart(e);
              }
            })
            .onActionUpdate((e) => {
              if (this.currentTool === 'text') {
                const { localX, localY } = e.fingerList[0];
                this.editorManager?.updateDraw(localX, localY);
              } else {
                this.onGestureUpdate(e);
              }
            })
            .onActionEnd(() => {
              if (this.currentTool === 'text') {
                this.editorManager?.endDraw();
              } else {
                this.onGestureEnd();
              }
            })
        )
      )
  }
}

四、核心难点解析

4.1 多行文本精确换行与行高计算

  • 使用 ascent + descent 获得实际文字高度,避免字体乘系数误差。
  • 行间距采用固定 pxLINE_SPACING = 8),保证视觉统一。
  • 手动换行符 \n 分段处理,每段内自动换行。

4.2 互斥手势避免冲突

  • GestureMode.Exclusive 确保单击、双击、拖拽只触发一种。
  • 双击放在最前,系统会延迟约 200ms 判断,双击成功时不触发单击。
  • 拖拽距离阈值 distance:5 防止微小移动触发拖拽。

4.3 撤回双栈独立管理

  • 涂鸦/马赛克使用 commands 数组,直接 pop 重绘。
  • 文字使用独立 textUndoStack,每次修改前深拷贝旧命令入栈,撤回时替换回数组。

4.4 删除按钮圆形命中区域

  • 命中测试中优先计算与右上角圆心的距离,radius=12
  • 绘制删除按钮时圆心与 (right, top) 对齐。

五、总结

本文完整实现了图片编辑器中的文字模块,涵盖了从数据模型、多行排版、组合手势、单位转换到撤回的方方面面。通过独立文字层设计,保证了与涂鸦/马赛克的完美共存;通过精确的度量计算,实现了高质量的排版和交互。如果觉得有用,请点赞、收藏、转发支持!

相关推荐
小雨下雨的雨2 小时前
通过鸿蒙PC Electron框架技术完成-井字棋游戏 - 实现详解
前端·javascript·游戏·华为·electron·鸿蒙
在水一缸2 小时前
深度解析:基于 3D Gaussian Splatting 技术的编辑器实践与原理
计算机视觉·3d·编辑器·aigc·3d建模·nerf·3d编辑器
zhangfeng11332 小时前
deepseek 适配了 华为升腾 是不是 用了类似Megatron-LM deepSpeed框架的??
人工智能·华为
AI_零食2 小时前
甄嬛人物日志-朗读升级 - 鸿蒙PC Electron框架完整技术实现指南
前端·学习·华为·electron·鸿蒙·鸿蒙系统
李二。2 小时前
AI翻译通(鸿蒙原生)—— 鸿蒙Next声明式UI翻译工具实战
人工智能·ui·harmonyos
Dream-Y.ocean2 小时前
[鸿蒙PC三方库适配实战] 跨平台媒体播放器 mpv 的 鸿蒙PC 平台迁移实践
华为·harmonyos
●VON2 小时前
AtomGit Flutter鸿蒙客户端:Issue管理
flutter·华为·架构·harmonyos·鸿蒙·issue
李二。2 小时前
PureHarmony · 文案创作工坊 —— 鸿蒙Next WaterFlow瀑布流 + AI写作助手实战
华为·harmonyos·ai写作
特立独行的猫a2 小时前
Tauri Demo 移植到鸿蒙PC上的交叉编译全流程实战总结
华为·rust·harmonyos·tauri·鸿蒙pc