HarmonyOS 字幕校对台连载03:重叠检测与冲突分组【共创稿事节】

定位算法已经能返回同一时刻的全部字幕,校对页面还需要把相互影响的区间组织起来。逐对弹出重叠提示容易产生重复操作:第一句与衔接句重叠,衔接句又与第二句重叠,用户实际上需要把三句放到同一个上下文中调整。但同组不意味着三句在某一时刻同时出现,也不意味着任意两句都重叠。

字幕重叠与冲突分组的关系

一、延续左闭右开的区间约定

沿用SrtParser.ets中的Cue、parseSrt和formatTime,以及CueIndex.ets中的IndexedCue和buildIndex。新增model/ConflictGroups.ets保存前三段代码,页面放入第四段。时间继续使用整数毫秒,起点包含、终点排除,结束于5000的字幕与开始于5000的字幕不构成重叠。

本节把“通过重叠关系能连通的字幕”归为一个冲突组,单独一条没有重叠的字幕不进入结果。组的范围是所有成员区间的并集所覆盖的起止范围,不是成员的共同交集;组内具体哪两条冲突,则单独保存为重叠对。界面可以用组组织编辑上下文,用重叠对解释问题位置。

import { Cue } from './SrtParser';
import { IndexedCue, buildIndex } from './CueIndex';

export class OverlapPair {
  id: string;
  leftNumber: number;
  rightNumber: number;
  startMs: number;
  endMs: number;
  constructor(left: Cue, right: Cue, startMs: number, endMs: number) {
    this.id = JSON.stringify([left.id, right.id]);
    this.leftNumber = left.number;
    this.rightNumber = right.number;
    this.startMs = startMs;
    this.endMs = endMs;
  }
}

export class ConflictGroup {
  id: string;
  startMs: number;
  endMs: number;
  members: Cue[] = [];
  pairs: OverlapPair[] = [];
  constructor(cues: Cue[]) {
    if (cues.length < 2) { throw new Error('冲突组至少需要两条字幕'); }
    const ids: string[] = [];
    this.startMs = cues[0].startMs;
    this.endMs = cues[0].endMs;
    for (let i = 0; i < cues.length; i++) {
      const cue = cues[i];
      ids.push(cue.id);
      this.startMs = Math.min(this.startMs, cue.startMs);
      this.endMs = Math.max(this.endMs, cue.endMs);
      this.members.push(new Cue(cue.id, cue.number,
        cue.startMs, cue.endMs, cue.text));
    }
    ids.sort();
    this.id = JSON.stringify(ids);
  }
}

组标识来自成员身份集合,显示序号只是页面上的第几个组。字幕改字但成员关系不变时,组身份可以保持;成员合并或拆分时应产生不同身份。不要把组范围拼成唯一标识,因为两组不同字幕可能在某一版数据中拥有相同的起止值。

二、扫描时保留组内最大结束时间

buildIndex已经给出按开始时间排序的快照,并保留原文order。本节复用它做扫描:下一条的开始时间小于当前组最大结束时间,就与组内至少一条字幕重叠;否则当前组结束。这里比较的是组的最大结束时间,不能只比较前一条字幕的结束时间。

export function buildGroups(cues: Cue[]): ConflictGroup[] {
  const entries = buildIndex(cues);
  const groups: ConflictGroup[] = [];
  let pending: IndexedCue[] = [];
  let endMs: number = 0;
  const flush = (): void => {
    if (pending.length < 2) { return; }
    const ordered = pending.slice().sort(
      (a: IndexedCue, b: IndexedCue): number => a.order - b.order);
    const members: Cue[] = [];
    for (let i = 0; i < ordered.length; i++) {
      members.push(ordered[i].cue);
    }
    const group = new ConflictGroup(members);
    group.pairs = overlapPairs(group.members);
    groups.push(group);
  };
  for (let i = 0; i < entries.length; i++) {
    const entry = entries[i];
    if (entry.cue.startMs < endMs) {
      pending.push(entry);
      endMs = Math.max(endMs, entry.cue.endMs);
    } else {
      flush();
      pending = [entry];
      endMs = entry.cue.endMs;
    }
  }
  flush();
  return groups;
}

假设长字幕覆盖1000—10000毫秒,中间有一条2000—3000的短字幕,后面还有6000—8000的一条。最大结束时间保持10000,三条属于同一个连通组;若每次都把endMs改成当前短字幕的3000,后面的字幕就会被错误分开。Math.max在这里保留的是组的覆盖能力,而不是单条字幕长度。

扫描顺序用于判断连通性,组内显示顺序仍按原文order恢复。这样用户看到的字幕顺序与导入文本一致,定位页面与冲突页面也不会各自重排正文。最后一次flush不能遗漏,否则文件末尾的冲突组不会进入结果。

三、重叠对记录真正的交集

连通组只是编辑范围。要说明问题,还需要逐对检查组内成员,交集开始取较大的起点,结束取较小的终点;只有开始严格小于结束才形成重叠对。每对只枚举一次,避免甲乙和乙甲重复出现。

export function overlapPairs(cues: Cue[]): OverlapPair[] {
  const result: OverlapPair[] = [];
  for (let i = 0; i < cues.length; i++) {
    for (let j = i + 1; j < cues.length; j++) {
      const left = cues[i];
      const right = cues[j];
      const start = Math.max(left.startMs, right.startMs);
      const end = Math.min(left.endMs, right.endMs);
      if (start < end) {
        result.push(new OverlapPair(left, right, start, end));
      }
    }
  }
  return result;
}

示例第一句为1000—3000,第二句为3500—5000,新增衔接句为2500—4000。三句形成一个组,但只有第一句与衔接句、第二句与衔接句两对重叠,第一句和第二句之间有500毫秒空隙。再新增5000—6500的收尾句,它只与前一段端点相接,应保持独立,不进入该冲突组。

组内逐对枚举的成本与成员数量平方相关,输出本身在密集重叠时也可能达到平方规模。若界面只需要冲突组数量,可以暂时不生成全部重叠对;如果需要逐项解释,则可采用分页或按选中组延迟计算。不能把扫描阶段的线性遍历复杂度直接套到包含排序和重叠对枚举的整个函数上。

四、把组与重叠对放到同一张卡片

下列页面在原有两句示例文本后增加衔接句与收尾句,保留输入次序。初始化先完成解析与分组,成功后才替换groups。组内成员显示原文顺序,卡片范围显示整个连通组,重叠对直接列出交集毫秒值,便于下一步跳转编辑。

import { parseSrt, Cue, formatTime } from '../model/SrtParser';
import { ConflictGroup, OverlapPair, buildGroups } from '../model/ConflictGroups';

@Entry
@Component
struct Index {
  private source: string =
    '1\n00:00:01,000 --> 00:00:03,000\n第一句字幕\n\n' +
    '2\n00:00:03,500 --> 00:00:05,000\n第二句字幕\n\n' +
    '3\n00:00:02,500 --> 00:00:04,000\n衔接字幕\n\n' +
    '4\n00:00:05,000 --> 00:00:06,500\n收尾字幕';
  @State groups: ConflictGroup[] = [];
  @State message: string = '';

  aboutToAppear(): void {
    try {
      const cues = parseSrt(this.source);
      const next = buildGroups(cues);
      let count: number = 0;
      for (let i = 0; i < next.length; i++) {
        count += next[i].pairs.length;
      }
      this.groups = next;
      this.message = `${next.length} 个冲突组 · ${count} 对重叠`;
    } catch (error) {
      this.message = (error as Error).message;
    }
  }

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('字幕校对台').fontSize(26).fontWeight(FontWeight.Bold)
        Text('重叠冲突检查').fontColor('#6B8180')
        Text(this.message).width('100%').fontSize(20)
        ForEach(this.groups, (group: ConflictGroup, index: number) => {
          Column({ space: 12 }) {
            Text(`冲突组 ${index + 1}`).fontSize(22)
            Text(`${formatTime(group.startMs)} → ${formatTime(group.endMs)}`)
              .fontSize(14).fontColor('#6B8180')
            ForEach(group.members, (cue: Cue) => {
              Column({ space: 6 }) {
                Text(cue.text).fontSize(18)
                Text(`${formatTime(cue.startMs)} → ${formatTime(cue.endMs)}`)
                  .fontSize(13).fontColor('#6B8180')
              }.alignItems(HorizontalAlign.Start).padding(10)
              .width('100%').backgroundColor('#F3F9F7').borderRadius(8)
            }, (cue: Cue) => cue.id)
            ForEach(group.pairs, (pair: OverlapPair) => {
              Text(`${pair.leftNumber} ↔ ${pair.rightNumber}:` +
                `${pair.startMs}—${pair.endMs} ms`).fontSize(16)
            }, (pair: OverlapPair) => pair.id)
          }.alignItems(HorizontalAlign.Start).padding(16)
          .width('100%').backgroundColor('#FFFFFF').borderRadius(12)
        }, (group: ConflictGroup) => group.id)
        Text('相接端点不计重叠').fontColor('#6B8180')
      }.width('100%').padding(16)
    }.width('100%').height('100%').backgroundColor('#F0F9F7')
  }
}

开发环境中的连通组扫描与结果预览

五、用图关系检查分组是否正确

可以用一个独立参照检查分组:对全部字幕逐对建立重叠关系,再用图遍历寻找连通分量,只保留含两条以上字幕的分量。这个参照不使用扫描endMs,因此能够发现最大结束时间更新错误、尾组遗漏和端点判断不一致。比较时把每组成员id排序,再比较组集合,而不是只比较数量。

检查样本至少包含链式重叠、长区间包围短区间、同起点、同终点、完全相同区间、仅端点相接,以及中间存在空隙的两组。原文输入顺序也应打乱后再运行,确认分组关系不依赖文件顺序,只有组内显示顺序随原文变化。

手机界面中的一个冲突组与两对重叠

六、编辑之后要同时更新哪些结果

调整衔接句的结束时间,可能让原组拆成两部分;删除衔接句,则第一句与第二句之间原本存在的空隙会直接显露。组身份应根据新的成员集合重新计算,旧组的选中状态不能直接套到新的组下标。可以保留当前正在编辑的字幕id,再在新结果中寻找它所属的组,找不到时回到普通字幕列表。

组范围、重叠对和上一节的定位索引都派生于同一版Cue集合。编辑成功后应从这份新集合重建派生结果,再作为同一个版本提交给页面。只更新卡片上的起止文字,不更新重叠对,会让用户看到已经不存在的问题;只更新冲突组,不更新定位索引,则会让跳转后的内容仍停留在旧时间轴。

对于包含多语种或多轨道的字幕,需要先决定哪些轨道之间允许重叠。如果不同轨道本来就应同时出现,不能把全部条目无差别送进同一冲突集合。可以在模型中增加轨道标识,在每条轨道内分别构建索引和分组,再根据产品规则进行跨轨道检查。这是业务规则的扩展,不应通过修改端点比较符号来实现。

七、让诊断数据随编辑结果更新

冲突组不是用户输入,而是可重建的派生数据,因此保存工程时应优先保存Cue集合和规则版本。重新打开后按相同端点规则生成组与重叠对,能够避免某个旧诊断长期残留。若需要记录用户已经处理过哪些问题,应保存处理动作及其对应字幕身份,再在新诊断中判断是否仍适用。

重叠对的交集也可以作为定位入口,但跳转时间应明确取交集起点或中点,不能把整个组的起点当成每一对的重叠起点。本例第二句与衔接句的重叠从3500开始,跳到组起点1000只会看到第一句,无法直接呈现该问题。沿用上一节locate函数,可在选定毫秒值重新取得全部命中字幕,确保导航结果与诊断一致。

把连通范围和实际重叠对分别表达,校对页就能既保留上下文,又明确指出需要修改的区间。后续逐句编辑可以围绕同一条字幕身份提交事务,而不必把整组内容揉成一个不可拆分的文本块。

相关文档:ArkTS开发基础、ForEach键值与异常处理、文本组件状态同步。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐