feat: render custom channel emoji in descriptions and comments

Add attachmentRuns handling to parse_description so custom channel
emoji (image shortcodes such as :yt:) render as <img> tags instead of
falling back to their textual form.

The commentViewModel path (comments/youtube.cr) and the video description
path (parser.cr) both route through parse_description, which previously
only handled commandRuns (links). This change merges commandRuns and
attachmentRuns sorted by startIndex and emits an <img class=channel-emoji>
for image-type attachments, mirroring the behaviour of the older
content_to_comment_html implementation.

Fixes #5888
This commit is contained in:
water 2026-08-09 16:04:52 +08:00
parent eb407578a2
commit bab6a7b798

View File

@ -40,6 +40,8 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
return copied
end
private record Run, start_index : Int32, length : Int32, kind : Symbol, data : JSON::Any
def parse_description(desc, video_id : String) : String?
return "" if desc.nil?
@ -47,49 +49,121 @@ def parse_description(desc, video_id : String) : String?
return "" if content.empty?
commands = desc["commandRuns"]?.try &.as_a
if commands.nil?
# Slightly faster than HTML.escape, as we're only doing one pass on
# the string instead of five for the standard library
attachments = desc["attachmentRuns"]?.try &.as_a
# If no runs at all, fall back to simple escape
if commands.nil? && attachments.nil?
return String.build do |str|
content_size = content.ascii_only? ? content.size : utf16_length(content)
copy_string(str, content.each_codepoint, content_size)
end
end
# 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
# copied byte in a special manner, preventing the use of regular string copy.
iter = content.each_codepoint
# Merge commandRuns and attachmentRuns sorted by startIndex
runs = [] of Run
if commands
commands.each do |cmd|
runs << Run.new(
start_index: cmd["startIndex"].as_i,
length: cmd["length"].as_i,
kind: :command,
data: cmd
)
end
end
if attachments
attachments.each do |att|
runs << Run.new(
start_index: att["startIndex"].as_i,
length: att["length"].as_i,
kind: :attachment,
data: att
)
end
end
runs.sort_by!(&.start_index)
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 |run|
cmd_start = run.start_index
cmd_length = run.length
# 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
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)
end
link = cmd_content
if on_tap = command.dig?("onTap", "innertubeCommand")
link = parse_link_endpoint(on_tap, cmd_content, video_id)
if run.kind == :command
# Handle link commands
link = cmd_content
if on_tap = run.data.dig?("onTap", "innertubeCommand")
link = parse_link_endpoint(on_tap, cmd_content, video_id)
end
str << link
elsif run.kind == :attachment
# Handle attachment runs (emoji images)
element = run.data["element"]?
if element.is_a?(JSON::Any) && element["type"]?.try &.as_s == "imageType"
image = element["image"]?
if image.is_a?(JSON::Any)
sources = image["sources"]?.try &.as_a
if sources && sources[0]?
source = sources[0]
# Get the emoji image URL
emoji_url = source["url"]?.try &.as_s
# Fallback for clientResource format (badges)
if emoji_url.nil? || emoji_url.empty?
emoji_url = source.dig?("clientResource", "imageName").try &.as_s
end
if emoji_url && !emoji_url.empty?
# Get accessibility label
alt_text = image.dig?("accessibility", "accessibilityData", "label").try &.as_s || cmd_content
# Build the img tag
if emoji_url.starts_with?("http://") || emoji_url.starts_with?("https://")
str << %(<img alt=") << alt_text << "\" "
str << %(src=") << emoji_url << "\" "
str << %(title=") << alt_text << "\" "
str << %(class="channel-emoji" />)
else
# For internal resource names (badges), skip rendering
str << cmd_content
end
else
str << cmd_content
end
else
str << cmd_content
end
else
str << cmd_content
end
else
str << cmd_content
end
end
str << link
index += cmd_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
end
end
end