Render custom channel emoji in comments and descriptions

Custom channel emoji are only carried in the attachment runs. The text
content just holds the ":shortcode:" placeholder, which was rendered
as-is because parse_description only handled commandRuns.

Parse attachmentRuns alongside commandRuns, walking both in positional
order since they index into the same string, and substitute the image.

Standard unicode emoji are sent as attachment runs too, but their text
content is already the emoji character and renders fine, so those are
left as text rather than proxying an image for every one of them.
This commit is contained in:
Deepanshu Sagore 2026-08-12 03:58:54 +05:30
parent 8fde720f94
commit f18762bcde
2 changed files with 167 additions and 14 deletions

View File

@ -0,0 +1,103 @@
require "../../parsers_helper.cr"
Spectator.describe "parse_description" do
it "renders custom channel emoji as an image" do
# Custom emoji only exist in the attachment run; the text content carries
# the ":shortcode:" placeholder, which must not be shown as-is.
raw = {
"content" => ":face-red-heart-shape: hello",
"attachmentRuns" => [
{
"startIndex" => 0,
"length" => 22,
"element" => {
"type" => {
"imageType" => {
"image" => {
"sources" => [
{"url" => "https://lh3.googleusercontent.com/abc=s16-w24-h24-c-k-nd", "width" => 16, "height" => 16},
],
},
},
},
"properties" => {
"accessibilityProperties" => {"label" => "face-red-heart-shape"},
},
},
},
],
}
result = parse_description(JSON.parse(raw.to_json), "")
expect(result).to eq(
%(<img alt="face-red-heart-shape" src="/ggpht/abc=s16-w24-h24-c-k-nd" ) +
%(title="face-red-heart-shape" width="16" height="16" class="channel-emoji" /> hello)
)
end
it "leaves standard unicode emoji untouched" do
# Standard emoji also come with an attachment run, but the text content is
# already the emoji character, so it must be kept as text.
raw = {
"content" => "Nice sharing 🎉",
"attachmentRuns" => [
{
"startIndex" => 13,
"length" => 2,
"element" => {
"type" => {
"imageType" => {
"image" => {
"sources" => [
{"url" => "https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u1f389.png", "width" => 16, "height" => 16},
],
},
},
},
"properties" => {
"accessibilityProperties" => {"label" => "🎉"},
},
},
},
],
}
result = parse_description(JSON.parse(raw.to_json), "")
expect(result).to eq("Nice sharing 🎉")
end
it "keeps text offsets correct when an emoji precedes other content" do
# The emoji run is consumed from the same iterator as the surrounding
# text, so a wrong length here would shift everything after it.
raw = {
"content" => "a:face-red-heart-shape:b",
"attachmentRuns" => [
{
"startIndex" => 1,
"length" => 22,
"element" => {
"type" => {
"imageType" => {
"image" => {
"sources" => [
{"url" => "https://lh3.googleusercontent.com/abc", "width" => 16, "height" => 16},
],
},
},
},
"properties" => {
"accessibilityProperties" => {"label" => "face-red-heart-shape"},
},
},
},
],
}
result = parse_description(JSON.parse(raw.to_json), "")
expect(result).to start_with("a<img ")
expect(result).to end_with("/>b")
end
end

View File

@ -40,6 +40,37 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
return copied return copied
end end
# Custom channel emoji are sent as an attachment run holding the image, while
# the text content only carries a ":shortcode:" placeholder. If the image is
# not substituted in, that raw placeholder is what gets displayed.
#
# Standard Unicode emoji are sent as attachment runs too, but their text
# content is the emoji character itself, which already renders correctly. Those
# are left as-is, so we don't proxy an image for every single emoji.
private def attachment_run_to_html(run : JSON::Any, text : String) : String
return text if !(text.starts_with?(':') && text.ends_with?(':') && text.size > 2)
source = run.dig?("element", "type", "imageType", "image", "sources", 0)
return text if source.nil?
url = source["url"]?.try &.as_s
return text if url.nil?
label = run.dig?("element", "properties", "accessibilityProperties", "label")
.try &.as_s || text
width = source["width"]?.try &.as_i? || 16
height = source["height"]?.try &.as_i? || 16
return String.build do |str|
str << %(<img alt=") << HTML.escape(label) << %(" )
str << %(src="/ggpht) << URI.parse(url).request_target << %(" )
str << %(title=") << HTML.escape(label) << %(" )
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?
@ -47,7 +78,9 @@ def parse_description(desc, video_id : String) : String?
return "" if content.empty? return "" if content.empty?
commands = desc["commandRuns"]?.try &.as_a commands = desc["commandRuns"]?.try &.as_a
if commands.nil? attachments = desc["attachmentRuns"]?.try &.as_a
if commands.nil? && attachments.nil?
# 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|
@ -56,6 +89,17 @@ def parse_description(desc, video_id : String) : String?
end end
end end
# Both kinds of run index into the same string, so they have to be walked
# together in positional order.
runs = [] of {Int32, Int32, Bool, JSON::Any}
commands.try &.each do |command|
runs << {command["startIndex"].as_i, command["length"].as_i, false, command}
end
attachments.try &.each do |attachment|
runs << {attachment["startIndex"].as_i, attachment["length"].as_i, true, attachment}
end
runs.sort_by! { |run| run[0] }
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints # Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
# (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
@ -65,26 +109,32 @@ def parse_description(desc, video_id : String) : String?
index = 0 index = 0
return String.build do |str| return String.build do |str|
commands.each do |command| runs.each do |(run_start, run_length, is_attachment, run)|
cmd_start = command["startIndex"].as_i # A command and an attachment can cover the same characters. The
cmd_length = command["length"].as_i # iterator can only move forward, so skip anything already consumed.
next if run_start < index
# Copy the text chunk between this command and the previous if needed. # Copy the text chunk between this run and the previous if needed.
length = cmd_start - index length = run_start - index
index += copy_string(str, iter, length) index += copy_string(str, iter, length)
# We need to copy the command's text using the iterator # We need to copy the run's text using the iterator
# and the special function defined above. # and the special function defined above.
cmd_content = String.build(cmd_length) do |str2| run_content = String.build(run_length) do |str2|
copy_string(str2, iter, cmd_length) copy_string(str2, iter, run_length)
end end
link = cmd_content if is_attachment
if on_tap = command.dig?("onTap", "innertubeCommand") str << attachment_run_to_html(run, run_content)
link = parse_link_endpoint(on_tap, cmd_content, video_id) else
link = run_content
if on_tap = run.dig?("onTap", "innertubeCommand")
link = parse_link_endpoint(on_tap, run_content, video_id)
end end
str << link str << link
index += cmd_length end
index += run_length
end end
# Copy the end of the string (past the last command). # Copy the end of the string (past the last command).