diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts
index c23b45911..256310b0d 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts
@@ -3,6 +3,7 @@ import {
removeElementFromHtml,
patchElementInHtml,
probeElementInSource,
+ splitElementInHtml,
} from "./sourceMutation.js";
describe("removeElementFromHtml", () => {
@@ -126,6 +127,29 @@ describe("patchElementInHtml", () => {
expect(result).not.toContain("Hello World");
});
+ it("patches text content when inner wrapper is
", () => {
+ const html = `
`;
+ const { html: result, matched } = patchElementInHtml(html, { id: "el" }, [
+ { type: "text-content", property: "", value: "Replaced" },
+ ]);
+
+ expect(matched).toBe(true);
+ expect(result).toContain("Replaced");
+ expect(result).not.toContain("Original");
+ // Outer div structure preserved — p tag still wraps the text
+ expect(result).toMatch(/
]*>Replaced<\/p>/);
+ });
+
+ it("patches text content when inner wrapper is ", () => {
+ const html = `Original
`;
+ const { html: result } = patchElementInHtml(html, { id: "el" }, [
+ { type: "text-content", property: "", value: "Replaced" },
+ ]);
+
+ expect(result).toContain("Replaced");
+ expect(result).toMatch(/]*>Replaced<\/span>/);
+ });
+
it("applies multiple operations in one call", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "blue" },
@@ -362,6 +386,61 @@ describe("probeElementInSource", () => {
});
});
+describe("splitElementInHtml", () => {
+ it("splits a data-end element and removes stale data-duration from both halves", () => {
+ const html = ``;
+ const { html: result, matched, newId } = splitElementInHtml(html, { id: "el" }, 5, "el-b");
+
+ expect(matched).toBe(true);
+ expect(newId).toBe("el-b");
+ // First half: data-end="5", no data-duration
+ expect(result).toMatch(/id="el"[^>]*data-end="5"/);
+ expect(result).not.toMatch(/id="el"[^>]*data-duration/);
+ // Second half: data-start="5", data-end="10", no data-duration
+ expect(result).toMatch(/id="el-b"[^>]*data-start="5"/);
+ expect(result).toMatch(/id="el-b"[^>]*data-end="10"/);
+ expect(result).not.toMatch(/id="el-b"[^>]*data-duration/);
+ });
+
+ it("splits a data-duration element and removes stale data-end from both halves", () => {
+ const html = ``;
+ const { html: result, matched } = splitElementInHtml(html, { id: "el" }, 5, "el-b");
+
+ expect(matched).toBe(true);
+ // First half: data-duration="5", no data-end
+ expect(result).toMatch(/id="el"[^>]*data-duration="5"/);
+ expect(result).not.toMatch(/id="el"[^>]*data-end/);
+ // Second half: data-duration="5", no data-end
+ expect(result).toMatch(/id="el-b"[^>]*data-duration="5"/);
+ expect(result).not.toMatch(/id="el-b"[^>]*data-end/);
+ });
+
+ it("produces correct media trim offset when data-playback-rate is absent", () => {
+ const html = ``;
+ const { html: result, matched } = splitElementInHtml(html, { id: "el" }, 4, "el-b");
+
+ expect(matched).toBe(true);
+ // Split at t=4, firstDuration=4, rate defaults to 1 → new trim = 2 + 4*1 = 6
+ expect(result).toContain('data-playback-start="6"');
+ // Must not produce NaN
+ expect(result).not.toContain("NaN");
+ });
+
+ it("returns matched:false when split time is outside element bounds", () => {
+ const html = ``;
+
+ expect(splitElementInHtml(html, { id: "el" }, 0, "el-b").matched).toBe(false);
+ expect(splitElementInHtml(html, { id: "el" }, 10, "el-b").matched).toBe(false);
+ expect(splitElementInHtml(html, { id: "el" }, 11, "el-b").matched).toBe(false);
+ });
+
+ it("returns matched:false when target not found", () => {
+ const html = ``;
+ const { matched } = splitElementInHtml(html, { id: "nonexistent" }, 5, "el-b");
+ expect(matched).toBe(false);
+ });
+});
+
// T7 — data-hf-id targeting (spec for R1).
// R1 adds `hfId?: string` to SourceMutationTarget and a `[data-hf-id="…"]` branch
// in findTargetElement (sourceMutation.ts:34). Convert from it.todo in the R1 PR.
diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts
index 8cdc5ae05..a95df70d0 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.ts
@@ -195,7 +195,13 @@ export function patchElementInHtml(
}
break;
case "text-content":
- if (op.value != null) htmlEl.textContent = op.value;
+ if (op.value != null) {
+ // The generator wraps text in a single inner element; target it to preserve outer structure.
+ // Only unwrap one level when there is exactly one element child (the text container).
+ const inner = htmlEl.children.length === 1 ? htmlEl.firstElementChild : null;
+ const textTarget = inner ? (inner as unknown as HTMLElement) : htmlEl;
+ textTarget.textContent = op.value;
+ }
break;
}
}
@@ -219,6 +225,36 @@ export interface SplitElementResult {
newId: string | null;
}
+function resolveElementTiming(el: Element): {
+ start: number;
+ duration: number;
+ usesDataEnd: boolean;
+} {
+ const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
+ // Generator writes data-end; legacy elements use data-duration. Support both.
+ const usesDataEnd = el.hasAttribute("data-end");
+ const duration = usesDataEnd
+ ? parseFloat(el.getAttribute("data-end") ?? "") - start || 0
+ : parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
+ return { start, duration, usesDataEnd };
+}
+
+function setElementDuration(
+ el: Element,
+ start: number,
+ duration: number,
+ usesDataEnd: boolean,
+): void {
+ if (usesDataEnd) {
+ const endTime = String(Math.round((start + duration) * 1000) / 1000);
+ el.setAttribute("data-end", endTime);
+ el.removeAttribute("data-duration"); // clean up legacy sibling attr
+ } else {
+ el.setAttribute("data-duration", String(Math.round(duration * 1000) / 1000));
+ el.removeAttribute("data-end"); // clean up if previously migrated
+ }
+}
+
export function splitElementInHtml(
source: string,
target: SourceMutationTarget,
@@ -229,8 +265,7 @@ export function splitElementInHtml(
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
- const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
- const duration = parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
+ const { start, duration, usesDataEnd } = resolveElementTiming(el);
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
return { html: source, matched: false, newId: null };
}
@@ -241,7 +276,7 @@ export function splitElementInHtml(
const clone = el.cloneNode(true) as HTMLElement;
clone.setAttribute("id", newId);
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
- clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000));
+ setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
// Adjust media trim offset for the second half
const playbackStartAttr = el.hasAttribute("data-playback-start")
@@ -251,7 +286,8 @@ export function splitElementInHtml(
: null;
if (playbackStartAttr) {
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
- const rate = parseFloat(el.getAttribute("data-playback-rate") ?? "1") || 1;
+ const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
+ const rate = Number.isFinite(rateRaw) ? rateRaw : 1;
clone.setAttribute(
playbackStartAttr,
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
@@ -259,7 +295,7 @@ export function splitElementInHtml(
}
// Trim the original element's duration
- el.setAttribute("data-duration", String(Math.round(firstDuration * 1000) / 1000));
+ setElementDuration(el, start, firstDuration, usesDataEnd);
// Insert clone after original
if (el.nextSibling) {