fix(media): preserve scanner prose ownership

This commit is contained in:
silent-reader-cn
2026-07-13 15:27:22 +08:00
committed by nesquena-hermes
parent e13a75b7b1
commit 946f7dbc2b
2 changed files with 141 additions and 41 deletions
+70 -29
View File
@@ -4261,6 +4261,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const parserFor = (data)=>{
return _smdParserKey(data, el);
};
const writeFadeText=(writeParent, writeData, writeText)=>{
if(!writeParent||_streamFadeSkipNode(writeParent)){
_smdAppendPlainText(writeParent, writeData, writeText, baseAddText);
return;
}
_streamFadeAppendText(writeParent, writeText);
};
renderer.add_text=(data,text)=>{
const parent=data&&data.nodes&&data.nodes[data.index];
if(!parent||_streamFadeSkipNode(parent)){baseAddText(data,text);return;}
@@ -4273,7 +4280,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const value=String(text||'');
const hasMediaPrefixTail=!!_smdMediaPrefixTail(value);
if(/MEDIA:/.test(value)||hasMediaTail||hasMediaPrefixTail){
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser);
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser, writeFadeText);
return;
}
const frag=document.createDocumentFragment();
@@ -4338,8 +4345,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// Without this, streamed prose shows MEDIA:C:\... as literal text until the
// turn settles and the full re-render swaps it for the real <img>.
// SAFETY & CROSS-CHUNK SPLITS (Greptile #1 + #2):
// 1. Prose slices go back to baseAddText (which calls createTextNode),
// NOT through DOMParser — mixed prose with HTML entities /
// 1. Prose slices go back to the owning text writer (text nodes or
// fade spans), NOT through DOMParser — mixed prose with HTML entities /
// malicious <img onerror> stays as literal text.
// 2. Each MEDIA token's HTML (from _inlineMediaHtmlForRef) is handed
// to DOMParser one at a time — only trusted markup is parsed.
@@ -4357,57 +4364,88 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
return '';
}
function _smdMediaTailSet(tailMap, parser, chunk, parent, baseAddText, data){
if(chunk) tailMap.set(parser, {chunk, parent, baseAddText, data});
function _smdAppendPlainText(parent, data, text, baseAddText){
const value=String(text||'');
if(parent&&parent.appendChild&&typeof document!=='undefined'&&document.createTextNode){
parent.appendChild(document.createTextNode(value));
return;
}
if(baseAddText) baseAddText(data,value);
}
function _smdMediaWriteText(parent, data, baseAddText, writeText, text){
if(writeText){
writeText(parent, data, String(text||''));
return;
}
if(baseAddText) baseAddText(data,String(text||''));
}
function _smdMediaTailSet(tailMap, parser, chunk, parent, baseAddText, data, writeText){
if(!tailMap||!parser) return;
if(chunk) tailMap.set(parser, {chunk, parent, baseAddText, data, writeText});
else tailMap.delete(parser);
}
function _smdMediaTailEntryChunk(entry){
return entry && typeof entry==='object' && Object.prototype.hasOwnProperty.call(entry,'chunk') ? entry.chunk : entry;
}
function _smdMediaTailSameOwner(entry, parent, baseAddText, writeText){
return !!entry && entry.parent===parent && entry.baseAddText===baseAddText && entry.writeText===writeText;
}
function _smdMediaRefHasReliableBoundary(rawRef){
const raw=String(rawRef||'');
if(/[?#]$/.test(raw)) return false;
const ref=raw.split(/[?#]/,1)[0];
return /\.(?:png|jpe?g|gif|webp|bmp|ico|svg|avif|mp4|webm|mov|m4v|mkv|avi|ogv|mp3|wav|ogg|m4a|aac|wma|opus|flac|oga|pdf|html?|csv|diff|patch|excalidraw)$/i.test(ref);
}
function _smdMediaTailFlushEntry(entry){
const chunk=_smdMediaTailEntryChunk(entry);
if(!chunk) return;
const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk));
const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1]));
if(!emitted && entry) _smdMediaWriteText(entry.parent, entry.data, entry.baseAddText, entry.writeText, chunk);
}
function _smdMediaTailFlush(parser){
if(!_SMD_MEDIA_TAIL||!parser||!_SMD_MEDIA_TAIL.get) return;
const entry=_SMD_MEDIA_TAIL.get(parser);
const chunk=_smdMediaTailEntryChunk(entry);
if(!chunk) return;
if(!entry) return;
_SMD_MEDIA_TAIL.delete(parser);
const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk));
const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1]));
if(!emitted && entry && entry.baseAddText) entry.baseAddText(entry.data, chunk);
_smdMediaTailFlushEntry(entry);
}
function _smdMediaAwareAddText(baseAddText, parent, data, text, tailMap, parser){
function _smdMediaAwareAddText(baseAddText, parent, data, text, tailMap, parser, writeText){
const value=String(text||'');
const tails=tailMap||(typeof _SMD_MEDIA_TAIL!=='undefined'&&_SMD_MEDIA_TAIL)||null;
const writeCurrent=(chunk)=>_smdMediaWriteText(parent, data, baseAddText, writeText, chunk);
if(!value){
if(baseAddText) baseAddText(data,'');
writeCurrent('');
return;
}
// Pull any pending tail from a previous (split) chunk, then clear it;
// this call will either complete it, re-buffer it, or flush it as text.
const leadEntry = tails && parser && tails.get ? tails.get(parser) : null;
const lead = _smdMediaTailEntryChunk(leadEntry);
if(lead && tails && parser && tails.delete) tails.delete(parser);
let leadEntry = tails && parser && tails.get ? tails.get(parser) : null;
let lead = _smdMediaTailEntryChunk(leadEntry);
if(lead && !_smdMediaTailSameOwner(leadEntry, parent, baseAddText, writeText)){
if(tails && parser && tails.delete) tails.delete(parser);
_smdMediaTailFlushEntry(leadEntry);
leadEntry=null;
lead='';
}else if(lead && tails && parser && tails.delete){
tails.delete(parser);
}
const combined = lead ? lead + value : value;
// Fast path: no MEDIA tokens in the (possibly combined) string.
if(!/MEDIA:/.test(combined)){
const prefixTail=_smdMediaPrefixTail(combined);
if(prefixTail && tails && parser && prefixTail.length < _MEDIA_TAIL_MAX){
const stable=combined.slice(0, combined.length-prefixTail.length);
if(stable && baseAddText) baseAddText(data, stable);
_smdMediaTailSet(tails, parser, prefixTail, parent, baseAddText, data);
if(stable) writeCurrent(stable);
_smdMediaTailSet(tails, parser, prefixTail, parent, baseAddText, data, writeText);
return;
}
if(baseAddText) baseAddText(data, combined);
writeCurrent(combined);
return;
}
// Walk the combined string, slicing into prose + MEDIA token runs.
// Prose runs go through baseAddText (text nodes). MEDIA tokens go
// through the single-token DOMParser helper only after a delimiter or
// Prose runs go through the owning text writer. MEDIA tokens go through
// the single-token DOMParser helper only after a delimiter or
// reliable filename suffix proves the ref is complete.
const re=/MEDIA:([^\s\)\]]+)/g;
let last=0, m;
@@ -4416,19 +4454,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const matchEnd = m.index + m[0].length;
if(m.index>last){
const slice = combined.slice(last, m.index);
if(baseAddText) baseAddText(data, slice);
writeCurrent(slice);
}
if(matchEnd===combined.length && !_smdMediaRefHasReliableBoundary(m[1])){
const candidate = combined.slice(m.index);
if(candidate.length < _MEDIA_TAIL_MAX){
unmatchedTail = candidate;
} else if(baseAddText){
baseAddText(data, candidate);
} else {
writeCurrent(candidate);
}
last = combined.length;
break;
}
if(!_smdAppendMediaNode(parent, m[1]) && baseAddText) baseAddText(data, m[0]);
if(!_smdAppendMediaNode(parent, m[1])) writeCurrent(m[0]);
last = matchEnd;
}
// Tail buffer — hold trailing bytes that look like an unterminated
@@ -4440,14 +4478,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const tailValue = tailMatch ? tailMatch[0] : prefixTail;
if(tailValue && rest.length < _MEDIA_TAIL_MAX){
const tailStart = tailMatch ? tailMatch.index : rest.length-prefixTail.length;
if(tailStart>0 && baseAddText) baseAddText(data, rest.slice(0, tailStart));
if(tailStart>0) writeCurrent(rest.slice(0, tailStart));
unmatchedTail = tailValue;
} else if(baseAddText){
baseAddText(data, rest);
} else {
writeCurrent(rest);
}
}
if(tails && parser){
_smdMediaTailSet(tails, parser, unmatchedTail, parent, baseAddText, data);
_smdMediaTailSet(tails, parser, unmatchedTail, parent, baseAddText, data, writeText);
}
}
// Single-token DOM splice. Only ever fed the output of
@@ -4516,12 +4554,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const renderer=window.smd.default_renderer(el);
const baseSetAttr=renderer.set_attr;
const baseAddText=renderer.add_text;
const writePlainText=(writeParent, writeData, writeText)=>{
_smdAppendPlainText(writeParent, writeData, writeText, baseAddText);
};
const parserFor = (data)=>{
return _smdParserKey(data, el);
};
renderer.add_text=(data,text)=>{
const parent=data&&data.nodes&&data.nodes[data.index];
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parserFor(data));
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parserFor(data), writePlainText);
};
renderer.set_attr=(data,attr,value)=>{
const isHref=window.smd&&attr===window.smd.HREF;
+71 -12
View File
@@ -32,6 +32,7 @@ from __future__ import annotations
import json
import pathlib
import re
import shutil
import subprocess
import unittest
@@ -43,8 +44,11 @@ NODE = shutil.which("node")
def _extract_js_function(src: str, name: str) -> str:
start = src.index(f"function {name}")
brace = src.index("{", start)
match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
if not match:
raise ValueError(f"Could not find JS function {name}")
start = match.start()
brace = src.index("{", match.end())
depth = 1
pos = brace + 1
while pos < len(src) and depth:
@@ -60,9 +64,13 @@ def _run_real_smd_media_cases() -> dict:
helpers = "\n".join(
[
_extract_js_function(MESSAGES_JS, "_smdMediaPrefixTail"),
_extract_js_function(MESSAGES_JS, "_smdAppendPlainText"),
_extract_js_function(MESSAGES_JS, "_smdMediaWriteText"),
_extract_js_function(MESSAGES_JS, "_smdMediaTailSet"),
_extract_js_function(MESSAGES_JS, "_smdMediaTailEntryChunk"),
_extract_js_function(MESSAGES_JS, "_smdMediaTailSameOwner"),
_extract_js_function(MESSAGES_JS, "_smdMediaRefHasReliableBoundary"),
_extract_js_function(MESSAGES_JS, "_smdMediaTailFlushEntry"),
_extract_js_function(MESSAGES_JS, "_smdMediaTailFlush"),
_extract_js_function(MESSAGES_JS, "_smdMediaAwareAddText"),
_extract_js_function(MESSAGES_JS, "_smdAppendMediaNode"),
@@ -73,6 +81,7 @@ def _run_real_smd_media_cases() -> dict:
_extract_js_function(MESSAGES_JS, "_streamFadeSkipNode"),
_extract_js_function(MESSAGES_JS, "_streamFadeReduceMotionEnabled"),
_extract_js_function(MESSAGES_JS, "_streamFadeBindCleanup"),
_extract_js_function(MESSAGES_JS, "_streamFadeAppendText"),
_extract_js_function(MESSAGES_JS, "_streamFadeRenderer"),
_extract_js_function(MESSAGES_JS, "_safeSmdRenderer"),
_extract_js_function(MESSAGES_JS, "_smdRendererWithoutUnderscoreEmphasis"),
@@ -113,6 +122,7 @@ def _run_real_smd_media_cases() -> dict:
" }\n"
" get childNodes(){ return this.children; }\n"
" get firstChild(){ return this.children[0] || null; }\n"
" get parentElement(){ return this.parentNode; }\n"
" get className(){ return this.attributes.class || ''; }\n"
" set className(value){ this.attributes.class=String(value); }\n"
" appendChild(child){\n"
@@ -131,6 +141,12 @@ def _run_real_smd_media_cases() -> dict:
" visit(this); return out;\n"
" }\n"
" get textContent(){ return this.nodeType===3 ? this.data : this.children.map(c=>c.textContent).join(''); }\n"
" set textContent(value){\n"
" if(this.nodeType===3){ this.data=String(value); return; }\n"
" this.children=[];\n"
" const text=String(value);\n"
" if(text) this.appendChild(document.createTextNode(text));\n"
" }\n"
" get outerHTML(){\n"
" if(this.nodeType===3) return esc(this.data);\n"
" if(this.nodeType===11) return this.children.map(c=>c.outerHTML).join('');\n"
@@ -154,6 +170,12 @@ def _run_real_smd_media_cases() -> dict:
" return { body: { firstChild: host } };\n"
"} };\n"
f"{helpers}\n"
"function collectTagTexts(root, tag){\n"
" const out=[]; const wanted=String(tag).toLowerCase();\n"
" const visit=node=>{ for(const child of node.children){ if(String(child.tagName||'').toLowerCase()===wanted) out.push(child.textContent); visit(child); } };\n"
" visit(root); return out;\n"
"}\n"
"function collectClassTexts(root, cls){ return root.querySelectorAll('.'+cls).map(node=>node.textContent); }\n"
"function renderChunks(chunks, mode){\n"
" postProcessCalls = 0; playbackCalls = 0;\n"
" const root=document.createElement('div');\n"
@@ -165,7 +187,7 @@ def _run_real_smd_media_cases() -> dict:
" smd.parser_end(parser);\n"
" _smdMediaTailFlush(parser);\n"
" _smdMediaTailClear(parser);\n"
" return { html: root.outerHTML, text: root.textContent, postProcessCalls, playbackCalls };\n"
" return { html: root.outerHTML, text: root.textContent, liTexts: collectTagTexts(root, 'li'), fadeWords: collectClassTexts(root, 'stream-fade-word'), postProcessCalls, playbackCalls };\n"
"}\n"
"function renderModes(chunks){ return { safe: renderChunks(chunks, 'safe'), fade: renderChunks(chunks, 'fade') }; }\n"
"const marker='MEDIA:';\n"
@@ -174,7 +196,9 @@ def _run_real_smd_media_cases() -> dict:
"const refSplit=renderModes(['MEDIA:C:/tmp/li', 've.png ']);\n"
"const finalExtensionless=renderModes(['MEDIA:https://fal.media/generated']);\n"
"const pdf=renderModes(['MEDIA:C:/tmp/report.pdf ']);\n"
"console.log(JSON.stringify({prefixSplits, refSplit, finalExtensionless, pdf}));\n"
"const falsePrefix=renderModes(['M', 'aybe plain prose ']);\n"
"const crossParent=renderModes(['- ME', '\\n- ow']);\n"
"console.log(JSON.stringify({prefixSplits, refSplit, finalExtensionless, pdf, falsePrefix, crossParent}));\n"
)
completed = subprocess.run(
[NODE, "--input-type=module", "-e", script],
@@ -272,16 +296,14 @@ class TestSmdMediaInStream(unittest.TestCase):
def test_media_interceptor_falls_back_to_base_when_no_token(self):
# Fast path: when the chunk + buffered tail carries no MEDIA token,
# the wrapper should delegate to the underlying add_text unchanged
# so the fade renderer's word-by-word animation is preserved for
# plain prose.
# the wrapper should delegate to the injected text writer so the
# owning renderer's semantics (plain text for safe mode, word fade for
# fade mode) survive for plain prose.
idx = MESSAGES_JS.index("function _smdMediaAwareAddText")
block = MESSAGES_JS[idx:idx + 6500]
self.assertTrue(
"baseAddText(data,value)" in block or "baseAddText(data," in block,
"Interceptor must delegate to baseAddText for plain text so the "
"underlying renderer's semantics survive",
)
self.assertIn("const writeCurrent=", block)
self.assertIn("writeCurrent(combined)", block)
self.assertIn("function _smdMediaWriteText", MESSAGES_JS)
def test_no_recursive_infinite_loop_via_baseAddText(self):
# Regression guard: the fade renderer's add_text is itself a wrapper.
@@ -418,6 +440,29 @@ class TestSmdMediaInStream(unittest.TestCase):
"concurrent streams don't cross-pollinate",
)
def test_tail_entries_preserve_original_text_owner(self):
# A buffered MEDIA-looking suffix belongs to the parent/writer that
# produced it. If the next smd add_text callback is for a different
# parent, the scanner must flush through the original owner instead
# of concatenating across DOM nodes.
idx = MESSAGES_JS.index("function _smdMediaTailSet")
block = MESSAGES_JS[idx:idx + 3500]
self.assertIn("writeText", block)
self.assertIn("function _smdMediaTailSameOwner", MESSAGES_JS)
self.assertIn("entry.parent===parent", MESSAGES_JS)
self.assertIn("entry.writeText===writeText", MESSAGES_JS)
self.assertIn("_smdMediaTailFlushEntry(leadEntry)", MESSAGES_JS)
def test_stream_fade_media_scanner_preserves_plain_prose_fade(self):
# False MEDIA-prefix tails (for example "M" + "aybe") still enter
# the scanner. Those plain prose writes must use the non-recursive
# fade appender, not the raw default_renderer text writer.
idx = MESSAGES_JS.index("function _streamFadeRenderer")
block = MESSAGES_JS[idx:idx + 7000]
self.assertIn("const writeFadeText=", block)
self.assertIn("_streamFadeAppendText(writeParent, writeText)", block)
self.assertIn("_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser, writeFadeText)", block)
def test_smd_parser_identity_is_bound_to_real_parser(self):
# smd's renderer.data does not expose a parser by default. Bind the
# created parser onto both renderer.data and the owning element so every
@@ -514,6 +559,20 @@ class TestSmdMediaRealParserBehaviour(unittest.TestCase):
self.assertGreaterEqual(result["postProcessCalls"], 1)
self.assertGreaterEqual(result["playbackCalls"], 1)
def test_real_smd_parser_false_prefix_plain_prose_keeps_fade(self):
result = self.cases["falsePrefix"]["fade"]
self.assertEqual(result["text"], "Maybe plain prose ")
self.assertEqual(result["fadeWords"], ["Maybe", "plain", "prose"])
self.assertIn('class="stream-fade-word is-new"', result["html"])
def test_real_smd_parser_refuses_cross_parent_tail_concat(self):
for mode, result in self.cases["crossParent"].items():
with self.subTest(mode=mode):
self.assertEqual(result["liTexts"], ["ME", "ow"])
if mode == "fade":
self.assertTrue(result["fadeWords"])
self.assertEqual("".join(result["fadeWords"]), "MEow")
if __name__ == "__main__":
import unittest