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
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?
return "" if desc.nil?
@ -47,7 +78,9 @@ def parse_description(desc, video_id : String) : String?
return "" if content.empty?
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
# the string instead of five for the standard library
return String.build do |str|
@ -56,6 +89,17 @@ def parse_description(desc, video_id : String) : String?
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
# (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
@ -65,26 +109,32 @@ def parse_description(desc, video_id : String) : String?
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 |(run_start, run_length, is_attachment, run)|
# A command and an attachment can cover the same characters. The
# 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.
length = cmd_start - index
# Copy the text chunk between this run and the previous if needed.
length = run_start - index
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.
cmd_content = String.build(cmd_length) do |str2|
copy_string(str2, iter, cmd_length)
run_content = String.build(run_length) do |str2|
copy_string(str2, iter, run_length)
end
link = cmd_content
if on_tap = command.dig?("onTap", "innertubeCommand")
link = parse_link_endpoint(on_tap, cmd_content, video_id)
if is_attachment
str << attachment_run_to_html(run, run_content)
else
link = run_content
if on_tap = run.dig?("onTap", "innertubeCommand")
link = parse_link_endpoint(on_tap, run_content, video_id)
end
str << link
end
str << link
index += cmd_length
index += run_length
end
# Copy the end of the string (past the last command).