diff --git a/spec/invidious/videos/description_attachment_runs_spec.cr b/spec/invidious/videos/description_attachment_runs_spec.cr
new file mode 100644
index 000000000..9f7bee5a8
--- /dev/null
+++ b/spec/invidious/videos/description_attachment_runs_spec.cr
@@ -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
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
))
+ end
+
+ it "escapes HTML entities in the fast path" do
+ desc = JSON.parse(%q({"content":"a 0xFFFF
+ copied += 1
+ end
+end
+
+# Builds an
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 << %(
)
+ end
+end
+
def parse_description(desc, video_id : String) : String?
return "" if desc.nil?
content = desc["content"].as_s
return "" if content.empty?
- commands = desc["commandRuns"]?.try &.as_a
- if commands.nil?
+ commands = (desc["commandRuns"]?.try &.as_a) || Array(JSON::Any).new
+ 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
# the string instead of five for the standard library
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
# 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.
+ #
+ # 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
index = 0
return String.build do |str|
- commands.each do |command|
- cmd_start = command["startIndex"].as_i
- cmd_length = command["length"].as_i
+ runs.each do |(start, length, node, is_attachment)|
+ # Defensive check against overlapping runs
+ next if start < index
- # Copy the text chunk between this command and the previous if needed.
- length = cmd_start - index
- index += copy_string(str, iter, length)
+ # Copy the text chunk between this run and the previous one if needed.
+ gap = start - index
+ index += copy_string(str, iter, gap)
- # We need to copy the command's text using the iterator
- # and the special function defined above.
- cmd_content = String.build(cmd_length) do |str2|
- copy_string(str2, iter, cmd_length)
+ if img = is_attachment ? attachment_to_img(node) : nil
+ # Skip the attached characters: the image replaces them entirely.
+ skip_string(iter, 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
-
- 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
+ index += length
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)
remaining_length = content_size - index
copy_string(str, iter, remaining_length) if remaining_length > 0