fix(description): render custom emoji attachments in comments and descriptions

YouTube's new commentEntityPayload format carries emojis as attachmentRuns
(startIndex/length into the plain text plus an image reference), which
parse_description() ignored so far - making every custom channel emoji
disappear from comments and video descriptions.

Merge attachmentRuns with the existing commandRuns handling (they share the
same UTF-16 coordinate space):

- Images hosted on /ggpht-proxiable domains (yt3.ggpht.com custom channel
  emojis) are rendered as <img class="channel-emoji"> tags, reusing the
  proxy and CSS class from the legacy comment renderer.
- Attachments pointing at www.youtube.com (standard unicode emojis rendered
  as images by YouTube) are skipped, keeping their original characters -
  they render fine natively without contacting YouTube's servers.

Fixes #5888
This commit is contained in:
soygeitoyt-lgtm 2026-08-22 03:40:55 -04:00
parent 163380d339
commit 080f87f0f5
2 changed files with 135 additions and 20 deletions

View File

@ -0,0 +1,50 @@
require "../../parsers_helper.cr"
Spectator.describe "parse_description" do
# Real InnerTube data captured from YouTube comments (2026-08).
# Standard emojis are attached as images hosted on www.youtube.com, while
# custom channel emojis are hosted on yt3.ggpht.com.
it "keeps standard emoji characters when their image is not proxyable" do
desc = JSON.parse(%q({"content":"Not going to lie. Your intro got me to sub! Watching you, everyone deserves to grow❤","attachmentRuns":[{"startIndex":84,"length":1,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u2764.png","width":16,"height":16}]},"playbackState":"IMAGE_PLAYBACK_STATE_STOPPED"}},"properties":{"layoutProperties":{"height":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"width":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"margin":{"left":{"value":2,"unit":"DIMENSION_UNIT_POINT"},"right":{"value":2,"unit":"DIMENSION_UNIT_POINT"}}},"accessibilityProperties":{"label":""}}},"alignment":"ALIGNMENT_VERTICAL_CENTER"}]}))
html = parse_description(desc, "Gy3bmuzmWMM")
expect(html).to eq(%(Not going to lie. Your intro got me to sub! Watching you, everyone deserves to grow\u{2764}\u{fe0f}))
expect(html.not_nil!.includes?("img")).to be_false
end
it "handles SMP emojis (2 UTF-16 units) right after multibyte-free text" do
desc = JSON.parse(%q({"content":"Feyrer is on 🔥! Thank you again, Master Jedi.","attachmentRuns":[{"startIndex":13,"length":2,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u1f525.png","width":16,"height":16}]},"playbackState":"IMAGE_PLAYBACK_STATE_STOPPED"}},"properties":{"layoutProperties":{"height":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"width":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"margin":{"left":{"value":2,"unit":"DIMENSION_UNIT_POINT"},"right":{"value":2,"unit":"DIMENSION_UNIT_POINT"}}},"accessibilityProperties":{"label":"🔥"}}},"alignment":"ALIGNMENT_VERTICAL_CENTER"}]}))
html = parse_description(desc, "Gy3bmuzmWMM")
expect(html).to eq("Feyrer is on \u{1F525}! Thank you again, Master Jedi.")
end
it "renders proxied channel emojis as images" do
# Fixture derived from a real capture: only the image URL was changed to
# a yt3.ggpht.com one (custom channel emoji) and its label set accordingly.
desc = JSON.parse(%q({"content":"Great video :_ToonThumbsUp: love it","attachmentRuns":[{"startIndex":12,"length":15,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://yt3.ggpht.com/UCxyz/aKgIYbrtFqmL8gT4576oCA=w24-h24-c-k-nd","width":24,"height":24}]}}},"properties":{"accessibilityProperties":{"label":"ToonThumbsUp"}}}}]}))
html = parse_description(desc, "Gy3bmuzmWMM")
expect(html).to eq(%(Great video <img alt="ToonThumbsUp" title="ToonThumbsUp" src="/ggpht/UCxyz/aKgIYbrtFqmL8gT4576oCA=w24-h24-c-k-nd" width="24" height="24" class="channel-emoji" /> love it))
end
it "renders links and channel emojis in order when both are present" do
# Minimal synthetic fixture: a command run without onTap stays as plain
# text, followed by a proxyable emoji attachment.
desc = JSON.parse(%q({"content":"see docs now :ToonFire:","attachmentRuns":[{"startIndex":13,"length":10,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://yt3.ggpht.com/x/fire.png"}]}}},"properties":{"accessibilityProperties":{"label":"ToonFire"}}}}],"commandRuns":[{"startIndex":4,"length":4}]}))
html = parse_description(desc, "Gy3bmuzmWMM")
expect(html).to eq(%(see docs now <img alt="ToonFire" title="ToonFire" src="/ggpht/x/fire.png" width="16" height="16" class="channel-emoji" />))
end
it "escapes HTML entities in the fast path" do
desc = JSON.parse(%q({"content":"a<b & c"}))
expect(parse_description(desc, "x")).to eq("a&lt;b &amp; c")
end
end

View File

@ -40,14 +40,60 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
return copied return copied
end end
private def skip_string(iter : Iterator, count : Int) : Nil
copied = 0
while copied < count
cp = iter.next
break if cp.is_a?(Iterator::Stop)
# A codepoint from the SMP counts twice
copied += 1 if cp > 0xFFFF
copied += 1
end
end
# Builds an <img> tag for a given attachment run. Only images hosted on
# domains proxied through /ggpht are supported; attachments pointing at
# www.youtube.com (standard unicode emojis rendered as images by YouTube)
# return nil, as the underlying text characters already render fine.
private def attachment_to_img(attachment : JSON::Any) : String?
source = attachment.dig?("element", "type", "imageType", "image", "sources", 0)
url = source.try &.dig?("url").try &.as_s
return unless url
uri = URI.parse(url)
host = uri.host.try &.downcase
return unless host.try(&.ends_with?("ggpht.com")) ||
host.try(&.ends_with?("googleusercontent.com"))
alt = attachment.dig?("element", "properties", "accessibilityProperties", "label").try &.as_s || ""
width = source.try &.dig?("width").try &.as_i || 16
height = source.try &.dig?("height").try &.as_i || 16
String.build do |str|
str << %(<img alt=") << HTML.escape(alt) << "\" "
str << %(title=") << HTML.escape(alt) << "\" "
str << %(src="/ggpht) << uri.request_target << "\" "
str << %(width=") << width << "\" "
str << %(height=") << height << "\" "
str << %(class="channel-emoji" />)
end
end
def parse_description(desc, video_id : String) : String? def parse_description(desc, video_id : String) : String?
return "" if desc.nil? return "" if desc.nil?
content = desc["content"].as_s content = desc["content"].as_s
return "" if content.empty? return "" if content.empty?
commands = desc["commandRuns"]?.try &.as_a commands = (desc["commandRuns"]?.try &.as_a) || Array(JSON::Any).new
if commands.nil? attachments = (desc["attachmentRuns"]?.try &.as_a) || Array(JSON::Any).new
# Only image attachments are supported for now
attachments = attachments.select do |attachment|
attachment.dig?("element", "type", "imageType", "image", "sources", 0, "url")
end
if commands.empty? && attachments.empty?
# Slightly faster than HTML.escape, as we're only doing one pass on # Slightly faster than HTML.escape, as we're only doing one pass on
# the string instead of five for the standard library # the string instead of five for the standard library
return String.build do |str| return String.build do |str|
@ -60,34 +106,53 @@ def parse_description(desc, video_id : String) : String?
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are # (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
# automatically decoded by the JSON parser. It means that we need to count # automatically decoded by the JSON parser. It means that we need to count
# copied byte in a special manner, preventing the use of regular string copy. # copied byte in a special manner, preventing the use of regular string copy.
#
# Links (commandRuns) and image attachments (attachmentRuns, used for custom
# channel emojis) share the same startIndex/length coordinate space and are
# merged into one ordered list of runs below.
runs = [] of {Int32, Int32, JSON::Any, Bool}
commands.each do |command|
runs << {command["startIndex"].as_i, command["length"].as_i, command, false}
end
attachments.each do |attachment|
runs << {attachment["startIndex"].as_i, attachment["length"].as_i, attachment, true}
end
runs.sort_by!(&.[0])
iter = content.each_codepoint iter = content.each_codepoint
index = 0 index = 0
return String.build do |str| return String.build do |str|
commands.each do |command| runs.each do |(start, length, node, is_attachment)|
cmd_start = command["startIndex"].as_i # Defensive check against overlapping runs
cmd_length = command["length"].as_i next if start < index
# Copy the text chunk between this command and the previous if needed. # Copy the text chunk between this run and the previous one if needed.
length = cmd_start - index gap = start - index
index += copy_string(str, iter, length) index += copy_string(str, iter, gap)
# We need to copy the command's text using the iterator if img = is_attachment ? attachment_to_img(node) : nil
# and the special function defined above. # Skip the attached characters: the image replaces them entirely.
cmd_content = String.build(cmd_length) do |str2| skip_string(iter, length)
copy_string(str2, iter, cmd_length) str << img
else
# We need to copy the command's text using the iterator
# and the special function defined above.
cmd_content = String.build(length) do |str2|
copy_string(str2, iter, length)
end
link = cmd_content
if !is_attachment && (on_tap = node.dig?("onTap", "innertubeCommand"))
link = parse_link_endpoint(on_tap, cmd_content, video_id)
end
str << link
end end
index += length
link = cmd_content
if on_tap = command.dig?("onTap", "innertubeCommand")
link = parse_link_endpoint(on_tap, cmd_content, video_id)
end
str << link
index += cmd_length
end end
# Copy the end of the string (past the last command). # Copy the end of the string (past the last run).
content_size = content.ascii_only? ? content.size : utf16_length(content) content_size = content.ascii_only? ? content.size : utf16_length(content)
remaining_length = content_size - index remaining_length = content_size - index
copy_string(str, iter, remaining_length) if remaining_length > 0 copy_string(str, iter, remaining_length) if remaining_length > 0