mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-05 13:42:38 +08:00
[feat] integrate CrossDesk CLI with terminal rendering and npm packaging
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-30
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CONTROLLER_SESSION_H_
|
||||
#define _CONTROLLER_SESSION_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <minirtc.h>
|
||||
#include <mutex>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <optional>
|
||||
#include <remote_action.h>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
struct DecodedFrame {
|
||||
unsigned width = 0, height = 0;
|
||||
uint64_t generation = 0, frame_id = 0;
|
||||
std::chrono::steady_clock::time_point queued;
|
||||
std::vector<uint8_t> nv12;
|
||||
};
|
||||
// One replaceable pending picture; Take transfers ownership to the consumer.
|
||||
// Callback memory is copied synchronously. Native descriptors never escape.
|
||||
class LatestFrame {
|
||||
public:
|
||||
bool Submit(const MiniRtcVideoFrame &, uint64_t generation);
|
||||
// Recycle the caller's old buffer only when a new frame is available.
|
||||
std::optional<DecodedFrame> Take(DecodedFrame *recycle = nullptr);
|
||||
void Reset(uint64_t generation);
|
||||
nlohmann::json Stats() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex_;
|
||||
std::optional<DecodedFrame> pending_;
|
||||
std::vector<uint8_t> spare_;
|
||||
uint64_t generation_ = 0, received_ = 0, dropped_ = 0, invalid_ = 0;
|
||||
};
|
||||
struct SessionOptions {
|
||||
std::string host = "api.crossdesk.cn", log_directory;
|
||||
int port = 9099, display = 0, fps = 8;
|
||||
int preview_width = 1920, preview_height = 1080;
|
||||
int quality = 2, max_bitrate_bps = 6000000;
|
||||
std::chrono::seconds timeout{30};
|
||||
};
|
||||
// All public methods except the destructor run on one owner thread. Call Tick
|
||||
// regularly. Library callbacks only enqueue bounded events or copy one frame.
|
||||
class ControllerSession {
|
||||
public:
|
||||
ControllerSession() = default;
|
||||
~ControllerSession();
|
||||
void Connect(SessionOptions options, std::string device,
|
||||
std::string password);
|
||||
void Tick();
|
||||
void Disconnect();
|
||||
bool SelectDisplay(int index);
|
||||
void SetPreview(int fps, int width, int height, int quality = -1,
|
||||
int max_bitrate_bps = -1);
|
||||
void SetControl(bool enabled);
|
||||
bool Text(const std::string &utf8);
|
||||
bool Key(int key, bool down);
|
||||
bool Pointer(float x, float y, int flag, int wheel = 0);
|
||||
std::optional<DecodedFrame> TakeFrame(DecodedFrame *recycle = nullptr);
|
||||
nlohmann::json Status() const;
|
||||
bool Connected() const { return state_ == "connected"; }
|
||||
bool Finished() const { return state_ == "failed" || state_ == "closed"; }
|
||||
uint64_t Generation() const { return generation_; }
|
||||
|
||||
private:
|
||||
struct Event {
|
||||
int kind, code;
|
||||
std::string data;
|
||||
};
|
||||
void Enqueue(Event event) noexcept;
|
||||
void Fail(const char *reason);
|
||||
void ClosePeers();
|
||||
Params Parameters();
|
||||
bool Send(const char *stream, const std::string &payload);
|
||||
void Preview();
|
||||
void ReleaseInput();
|
||||
static void Signal(SignalStatus, const char *, size_t, void *);
|
||||
static void Connection(ConnectionStatus, const char *, size_t, void *);
|
||||
static void Data(const char *, size_t, const char *, size_t, const char *,
|
||||
size_t, void *);
|
||||
static void Video(const MiniRtcVideoFrame *, const char *, size_t,
|
||||
const char *, size_t, void *);
|
||||
static void Network(const char *, size_t, TraversalMode,
|
||||
const MiniRtcNetTrafficStats *, const char *, size_t,
|
||||
void *);
|
||||
SessionOptions options_;
|
||||
PeerPtr *peer_ = nullptr;
|
||||
std::string state_ = "idle", error_, device_, password_, client_id_;
|
||||
uint64_t generation_ = 0;
|
||||
uint32_t preview_request_ = 0, preview_ack_ = 0;
|
||||
bool control_ = false, preview_supported_ = false, resolution_ = false;
|
||||
bool fps15_ = false, bandwidth_ = false, text_supported_ = false;
|
||||
bool preview_accepted_ = false;
|
||||
std::vector<HostDisplay> displays_;
|
||||
std::set<int> keys_, buttons_;
|
||||
float pointer_x_ = .5f, pointer_y_ = .5f;
|
||||
std::chrono::steady_clock::time_point deadline_;
|
||||
mutable std::mutex mutex_;
|
||||
std::deque<Event> events_;
|
||||
bool stopping_ = true, overflow_ = false;
|
||||
uint64_t callback_generation_ = 0;
|
||||
int callback_display_ = 0;
|
||||
MiniRtcNetTrafficStats network_{};
|
||||
TraversalMode traversal_ = UnknownMode;
|
||||
LatestFrame frames_;
|
||||
};
|
||||
nlohmann::json ParseBoundedJson(const std::string &text);
|
||||
std::string SafeText(const std::string &text, size_t limit = 160);
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,436 @@
|
||||
#include "controller_session.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <display_stream_id.h>
|
||||
#include <log.h>
|
||||
#include <preview_subscription.h>
|
||||
#include <stdexcept>
|
||||
#include <stream_names.h>
|
||||
|
||||
namespace crossdesk {
|
||||
using nlohmann::json;
|
||||
ControllerSession::~ControllerSession() { Disconnect(); }
|
||||
void ControllerSession::Enqueue(Event e) noexcept {
|
||||
try {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (stopping_)
|
||||
return;
|
||||
if (events_.size() == 64) {
|
||||
overflow_ = true;
|
||||
return;
|
||||
}
|
||||
events_.push_back(std::move(e));
|
||||
} catch (...) { /* Allocation failure cannot escape a C callback. */
|
||||
}
|
||||
}
|
||||
void ControllerSession::Signal(SignalStatus s, const char *id, size_t n,
|
||||
void *p) {
|
||||
try {
|
||||
static_cast<ControllerSession *>(p)->Enqueue(
|
||||
{0, s, id && n < 256 ? std::string(id, n) : ""});
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
void ControllerSession::Connection(ConnectionStatus s, const char *, size_t,
|
||||
void *p) {
|
||||
auto &session = *static_cast<ControllerSession *>(p);
|
||||
session.Enqueue({1, s, {}});
|
||||
}
|
||||
void ControllerSession::Data(const char *data, size_t n, const char *, size_t,
|
||||
const char *stream, size_t sn, void *p) {
|
||||
auto &session = *static_cast<ControllerSession *>(p);
|
||||
try {
|
||||
if (data && n && n <= 65536 && stream && sn < 64 &&
|
||||
(std::string(stream, sn) == kControlStream ||
|
||||
std::string(stream, sn) == kWebControlStream))
|
||||
session.Enqueue({2, 0, std::string(data, n)});
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
void ControllerSession::Video(const MiniRtcVideoFrame *f, const char *, size_t,
|
||||
const char *stream, size_t n, void *p) {
|
||||
auto &s = *static_cast<ControllerSession *>(p);
|
||||
try {
|
||||
std::lock_guard lock(s.mutex_);
|
||||
if (!s.stopping_ && f && stream && n < 64 &&
|
||||
std::string(stream, n) == MakeDisplayStreamId(s.callback_display_))
|
||||
s.frames_.Submit(*f, s.callback_generation_);
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
void ControllerSession::Network(const char *, size_t, TraversalMode mode,
|
||||
const MiniRtcNetTrafficStats *stats,
|
||||
const char *, size_t, void *p) {
|
||||
auto &session = *static_cast<ControllerSession *>(p);
|
||||
if (stats) {
|
||||
std::lock_guard lock(session.mutex_);
|
||||
session.network_ = *stats;
|
||||
session.traversal_ = mode;
|
||||
}
|
||||
}
|
||||
Params ControllerSession::Parameters() {
|
||||
Params p{};
|
||||
std::snprintf(p.signal_server_ip, sizeof(p.signal_server_ip), "%s",
|
||||
options_.host.c_str());
|
||||
std::snprintf(p.log_path, sizeof(p.log_path), "%s",
|
||||
options_.log_directory.c_str());
|
||||
p.signal_server_port = options_.port;
|
||||
p.hardware_acceleration = false;
|
||||
p.native_video_output = true;
|
||||
p.av1_encoding = false;
|
||||
p.turn_mode = TurnAutoUdpTcp;
|
||||
p.enable_srtp = true;
|
||||
p.video_content_type = VideoContentType::ScreenContent;
|
||||
p.video_quality = QualityLow;
|
||||
p.video_frame_rate = 30;
|
||||
p.video_degradation_preference =
|
||||
VideoDegradationPreference::MaintainResolution;
|
||||
p.on_signal_status = Signal;
|
||||
p.on_connection_status = Connection;
|
||||
p.on_net_status_report = Network;
|
||||
p.on_receive_data_buffer = Data;
|
||||
p.on_receive_video_frame = Video;
|
||||
// No audio callback, clipboard receiver, file writer or incoming input
|
||||
// handler.
|
||||
p.user_id = "cli";
|
||||
p.user_data = this;
|
||||
return p;
|
||||
}
|
||||
void ControllerSession::Connect(SessionOptions options, std::string device,
|
||||
std::string password) {
|
||||
Disconnect();
|
||||
if (device.size() != 9 ||
|
||||
device.find_first_not_of("0123456789") != std::string::npos)
|
||||
throw std::invalid_argument("device ID must contain 9 digits");
|
||||
if (password.size() > 256 || password.find('\0') != std::string::npos)
|
||||
throw std::invalid_argument("invalid password length or encoding");
|
||||
if (options.host.empty() || options.host.size() >= 256 || options.port < 1 ||
|
||||
options.port > 65535 || options.log_directory.empty() ||
|
||||
options.log_directory.size() >= 256 || options.display < 0 ||
|
||||
options.display > 63)
|
||||
throw std::invalid_argument(
|
||||
"invalid server, log directory or display index");
|
||||
options_ = std::move(options);
|
||||
device_ = std::move(device);
|
||||
password_ = std::move(password);
|
||||
state_ = "registering";
|
||||
error_.clear();
|
||||
displays_.clear();
|
||||
preview_supported_ = resolution_ = fps15_ = bandwidth_ = text_supported_ =
|
||||
false;
|
||||
preview_request_ = preview_ack_ = 0;
|
||||
preview_accepted_ = false;
|
||||
deadline_ = std::chrono::steady_clock::now() + options_.timeout;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
stopping_ = false;
|
||||
overflow_ = false;
|
||||
events_.clear();
|
||||
network_ = {};
|
||||
traversal_ = UnknownMode;
|
||||
callback_generation_ = ++generation_;
|
||||
callback_display_ = options_.display;
|
||||
frames_.Reset(generation_);
|
||||
}
|
||||
// The server assigns a temporary controller identity on this connection.
|
||||
// Never register a desktop identity or persist local credentials.
|
||||
client_id_.clear();
|
||||
minirtc::InitLogger(options_.log_directory);
|
||||
minirtc::get_logger()->set_level(spdlog::level::off);
|
||||
auto p = Parameters();
|
||||
peer_ = CreatePeer(&p);
|
||||
if (!peer_) {
|
||||
Fail("media_initialization_failed");
|
||||
return;
|
||||
}
|
||||
AddAudioStream(peer_, kAudioStream);
|
||||
AddDataStream(peer_, kDataStream, false);
|
||||
for (auto stream : {kMouseStream, kKeyboardStream, kControlStream})
|
||||
AddDataStream(peer_, stream, true);
|
||||
if (Init(peer_) != 0)
|
||||
Fail("media_initialization_failed");
|
||||
}
|
||||
void ControllerSession::Tick() {
|
||||
std::deque<Event> pending;
|
||||
bool overflow;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
pending.swap(events_);
|
||||
overflow = overflow_;
|
||||
}
|
||||
if (Finished() || state_ == "idle")
|
||||
return;
|
||||
if (overflow) {
|
||||
Fail("control_event_overflow");
|
||||
return;
|
||||
}
|
||||
for (auto &e : pending) {
|
||||
if (Finished())
|
||||
break;
|
||||
if (e.kind == 0) {
|
||||
if (e.code == SignalTlsCertError) {
|
||||
Fail("tls_certificate_error");
|
||||
break;
|
||||
}
|
||||
if (e.code == SignalFailed) {
|
||||
Fail("signaling_login_failed");
|
||||
break;
|
||||
}
|
||||
if (e.code == SignalClosed || e.code == SignalServerClosed ||
|
||||
e.code == SignalReconnecting) {
|
||||
Fail("signaling_disconnected");
|
||||
break;
|
||||
}
|
||||
if (e.code != SignalConnected)
|
||||
continue;
|
||||
if (state_ == "registering") {
|
||||
if (e.data.size() != 13 || e.data.rfind("cli-", 0) != 0 ||
|
||||
e.data.find_first_not_of("0123456789", 4) != std::string::npos) {
|
||||
Fail("cli_identity_not_supported");
|
||||
break;
|
||||
}
|
||||
client_id_ = e.data;
|
||||
state_ = "connecting";
|
||||
const auto join = device_ + "@" + password_;
|
||||
if (JoinConnection(peer_, join.c_str()) != 0)
|
||||
Fail("join_request_failed");
|
||||
std::fill(password_.begin(), password_.end(), '\0');
|
||||
password_.clear();
|
||||
}
|
||||
} else if (e.kind == 1) {
|
||||
if (e.code == ::Connected && !Connected()) {
|
||||
state_ = "connected";
|
||||
auto hello = MakeHostInformation("CrossDesk CLI", {}, false);
|
||||
Send(kControlStream, hello.to_json());
|
||||
FreeRemoteAction(hello);
|
||||
RemoteAction mute{};
|
||||
mute.type = audio_capture;
|
||||
mute.a = false;
|
||||
Send(kControlStream, mute.to_json());
|
||||
} else if (e.code == IncorrectPassword)
|
||||
Fail("incorrect_password");
|
||||
else if (e.code == NoSuchTransmissionId)
|
||||
Fail("device_not_found");
|
||||
else if (e.code == RemoteUnavailable)
|
||||
Fail("device_offline_or_unavailable");
|
||||
else if (e.code == Failed)
|
||||
Fail("connection_failed");
|
||||
else if (e.code == Closed || e.code == Disconnected)
|
||||
Fail("remote_disconnected");
|
||||
} else if (e.kind == 2 && Connected()) {
|
||||
auto j = ParseBoundedJson(e.data);
|
||||
if (!j.is_object())
|
||||
continue;
|
||||
if (j.value("type", json{}) == "deskhub_preview_status") {
|
||||
if (j.value("request_id", json{}) == preview_request_ &&
|
||||
j.contains("accepted") && j["accepted"].is_boolean()) {
|
||||
preview_ack_ = preview_request_;
|
||||
preview_accepted_ = j["accepted"];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
RemoteAction a{};
|
||||
if (!a.from_json(e.data))
|
||||
continue;
|
||||
if (a.type == host_infomation) {
|
||||
displays_.clear();
|
||||
for (size_t i = 0; i < std::min(a.i.display_num, size_t(64)); ++i)
|
||||
displays_.push_back({SafeText(a.i.display_list[i]), a.i.left[i],
|
||||
a.i.top[i], a.i.right[i], a.i.bottom[i]});
|
||||
preview_supported_ = a.i.supports_deskhub_preview;
|
||||
resolution_ = a.i.supports_deskhub_resolution;
|
||||
fps15_ = a.i.supports_deskhub_fps15;
|
||||
bandwidth_ = a.i.supports_deskhub_bandwidth;
|
||||
text_supported_ = a.i.supports_text_input;
|
||||
if (!SelectDisplay(options_.display))
|
||||
Fail("display_not_available");
|
||||
else
|
||||
Preview();
|
||||
}
|
||||
FreeRemoteAction(a);
|
||||
}
|
||||
}
|
||||
if (!Connected() && !Finished() &&
|
||||
std::chrono::steady_clock::now() > deadline_)
|
||||
Fail("connection_timeout");
|
||||
}
|
||||
bool ControllerSession::Send(const char *stream, const std::string &payload) {
|
||||
return peer_ && Connected() && !payload.empty() &&
|
||||
SendReliableDataFrame(peer_, payload.data(), payload.size(), stream) ==
|
||||
0;
|
||||
}
|
||||
void ControllerSession::ClosePeers() {
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
stopping_ = true;
|
||||
callback_generation_ = ++generation_;
|
||||
frames_.Reset(generation_);
|
||||
}
|
||||
if (peer_) {
|
||||
LeaveConnection(peer_, device_.c_str());
|
||||
DestroyPeer(&peer_);
|
||||
}
|
||||
std::fill(password_.begin(), password_.end(), '\0');
|
||||
password_.clear();
|
||||
}
|
||||
void ControllerSession::Fail(const char *reason) {
|
||||
ReleaseInput();
|
||||
error_ = reason;
|
||||
state_ = "failed";
|
||||
control_ = false;
|
||||
ClosePeers();
|
||||
}
|
||||
void ControllerSession::Disconnect() {
|
||||
if (state_ == "closed" && !control_)
|
||||
return;
|
||||
ReleaseInput();
|
||||
control_ = false;
|
||||
ClosePeers();
|
||||
state_ = "closed";
|
||||
}
|
||||
bool ControllerSession::SelectDisplay(int index) {
|
||||
if (!Connected() || index < 0 || size_t(index) >= displays_.size())
|
||||
return false;
|
||||
ReleaseInput();
|
||||
options_.display = index;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
callback_display_ = index;
|
||||
callback_generation_ = ++generation_;
|
||||
frames_.Reset(generation_);
|
||||
}
|
||||
RemoteAction a{};
|
||||
a.type = display_id;
|
||||
a.d = index;
|
||||
return Send(kControlStream, a.to_json());
|
||||
}
|
||||
void ControllerSession::SetPreview(int fps, int w, int h, int quality,
|
||||
int bitrate) {
|
||||
if (fps < 0 || fps > 30 || w < 2 || h < 2 || w > 3840 || h > 2160 ||
|
||||
quality < -1 || quality > 2 || bitrate < -1 || bitrate > 100000000)
|
||||
throw std::invalid_argument("preview out of range");
|
||||
quality = quality < 0 ? options_.quality : quality;
|
||||
bitrate = bitrate < 0 ? options_.max_bitrate_bps : bitrate;
|
||||
if (options_.fps == fps && options_.preview_width == w &&
|
||||
options_.preview_height == h && options_.quality == quality &&
|
||||
options_.max_bitrate_bps == bitrate)
|
||||
return;
|
||||
options_.fps = fps;
|
||||
options_.preview_width = w;
|
||||
options_.preview_height = h;
|
||||
options_.quality = quality;
|
||||
options_.max_bitrate_bps = bitrate;
|
||||
Preview();
|
||||
}
|
||||
void ControllerSession::Preview() {
|
||||
if (!Connected() || !preview_supported_)
|
||||
return;
|
||||
PreviewSubscription s;
|
||||
s.request_id = ++preview_request_;
|
||||
preview_accepted_ = false;
|
||||
s.fps = options_.fps == 0 ? 0
|
||||
: options_.fps <= 1 ? 1
|
||||
: options_.fps <= 8 || !fps15_ ? 8
|
||||
: 15;
|
||||
s.audio = false;
|
||||
s.quality = options_.quality;
|
||||
if (resolution_) {
|
||||
s.version = 2;
|
||||
s.width = options_.preview_width;
|
||||
s.height = options_.preview_height;
|
||||
}
|
||||
if (bandwidth_)
|
||||
s.max_bitrate_bps = options_.max_bitrate_bps;
|
||||
Send(kControlStream, s.ToJson().dump());
|
||||
}
|
||||
void ControllerSession::SetControl(bool enabled) {
|
||||
if (!enabled)
|
||||
ReleaseInput();
|
||||
control_ = enabled;
|
||||
}
|
||||
void ControllerSession::ReleaseInput() {
|
||||
auto keys = keys_;
|
||||
for (int k : keys)
|
||||
Key(k, false);
|
||||
auto buttons = buttons_;
|
||||
for (int b : buttons)
|
||||
Pointer(pointer_x_, pointer_y_, b + 1);
|
||||
keys_.clear();
|
||||
buttons_.clear();
|
||||
}
|
||||
bool ControllerSession::Key(int key, bool down) {
|
||||
if (!control_ || !Connected() || key < 0 || key > 255)
|
||||
return false;
|
||||
RemoteAction a{};
|
||||
a.type = keyboard;
|
||||
a.k = {size_t(key), 0, false, down ? key_down : key_up};
|
||||
if (down)
|
||||
keys_.insert(key);
|
||||
else
|
||||
keys_.erase(key);
|
||||
return Send(kKeyboardStream, a.to_json());
|
||||
}
|
||||
bool ControllerSession::Pointer(float x, float y, int flag, int wheel) {
|
||||
if (!control_ || !Connected() || !std::isfinite(x) || !std::isfinite(y) ||
|
||||
x < 0 || y < 0 || x > 1 || y > 1 || flag < 0 || flag > 8 ||
|
||||
std::abs(int64_t(wheel)) > 1200)
|
||||
return false;
|
||||
RemoteAction a{};
|
||||
a.type = mouse;
|
||||
a.m = {x, y, wheel, static_cast<MouseFlag>(flag)};
|
||||
pointer_x_ = x;
|
||||
pointer_y_ = y;
|
||||
if (flag >= 1 && flag <= 6) {
|
||||
if (flag & 1)
|
||||
buttons_.insert(flag);
|
||||
else
|
||||
buttons_.erase(flag - 1);
|
||||
}
|
||||
return Send(kMouseStream, a.to_json());
|
||||
}
|
||||
bool ControllerSession::Text(const std::string &text) {
|
||||
if (!control_ || !Connected() || !text_supported_ || text.empty() ||
|
||||
text.size() > kMaxTextInputBytes)
|
||||
return false;
|
||||
RemoteAction a{};
|
||||
a.type = text_input;
|
||||
a.text = text;
|
||||
try {
|
||||
return Send(kKeyboardStream, a.to_json());
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::optional<DecodedFrame> ControllerSession::TakeFrame(DecodedFrame *recycle) {
|
||||
return frames_.Take(recycle);
|
||||
}
|
||||
json ControllerSession::Status() const {
|
||||
json ds = json::array();
|
||||
for (size_t i = 0; i < displays_.size(); ++i)
|
||||
ds.push_back({{"index", i}, {"name", displays_[i].name}});
|
||||
std::lock_guard lock(mutex_);
|
||||
return {{"state", state_},
|
||||
{"client_id", client_id_},
|
||||
{"error", error_},
|
||||
{"generation", generation_},
|
||||
{"control", control_},
|
||||
{"displays", ds},
|
||||
{"display", options_.display},
|
||||
{"text_input", text_supported_},
|
||||
{"preview_supported", preview_supported_},
|
||||
{"preview_request", preview_request_},
|
||||
{"preview_ack", preview_ack_},
|
||||
{"preview_accepted", preview_accepted_},
|
||||
{"preview_width", options_.preview_width},
|
||||
{"preview_height", options_.preview_height},
|
||||
{"preview_quality", options_.quality},
|
||||
{"preview_bitrate_bps", options_.max_bitrate_bps},
|
||||
{"frames", frames_.Stats()},
|
||||
{"network",
|
||||
{{"video_bps", network_.video_inbound_stats.bitrate},
|
||||
{"rtt_ms", network_.rtt_ms},
|
||||
{"srtp", network_.srtp_active},
|
||||
{"traversal", int(traversal_)}}}};
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "controller_session.h"
|
||||
#include <climits>
|
||||
#include <cstring>
|
||||
|
||||
namespace crossdesk {
|
||||
bool LatestFrame::Submit(const MiniRtcVideoFrame &f, uint64_t generation) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (generation != generation_)
|
||||
return false;
|
||||
const size_t w = f.width, h = f.height;
|
||||
if (!w || !h || (w & 1) || (h & 1) || w > 8192 || h > 8192 ||
|
||||
w * h > 16777216) {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
const size_t size = w * h * 3 / 2;
|
||||
const auto *n = f.native_frame;
|
||||
const bool native = n && n->struct_size >= sizeof(*n) && n->width == w &&
|
||||
n->height == h && n->owner;
|
||||
if (native && n->type == MiniRtcNativeVideoFrameCpuNv12) {
|
||||
const auto &p = n->payload.cpu_nv12;
|
||||
if (!p.y_plane || !p.uv_plane || p.y_stride < w || p.uv_stride < w ||
|
||||
p.y_stride > INT_MAX || p.uv_stride > INT_MAX) {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
spare_.resize(size);
|
||||
for (size_t y = 0; y < h; ++y)
|
||||
std::memcpy(spare_.data() + y * w, p.y_plane + y * p.y_stride, w);
|
||||
for (size_t y = 0; y < h / 2; ++y)
|
||||
std::memcpy(spare_.data() + w * h + y * w, p.uv_plane + y * p.uv_stride,
|
||||
w);
|
||||
} else if (f.data && f.size >= size) {
|
||||
spare_.resize(size);
|
||||
std::memcpy(spare_.data(), f.data, size);
|
||||
} else if (native && n->copy_to_nv12) {
|
||||
spare_.resize(size);
|
||||
if (n->copy_to_nv12(n->owner, spare_.data(), size) != 0) {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
DecodedFrame frame{f.width,
|
||||
f.height,
|
||||
generation,
|
||||
++received_,
|
||||
std::chrono::steady_clock::now(),
|
||||
std::move(spare_)};
|
||||
// Keep a valid pending frame until the replacement has been copied.
|
||||
if (pending_) {
|
||||
spare_.swap(pending_->nv12);
|
||||
++dropped_;
|
||||
}
|
||||
pending_ = std::move(frame);
|
||||
return true;
|
||||
}
|
||||
std::optional<DecodedFrame> LatestFrame::Take(DecodedFrame *recycle) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!pending_)
|
||||
return {};
|
||||
auto result = std::move(pending_);
|
||||
pending_.reset();
|
||||
if (recycle && recycle->nv12.capacity() > spare_.capacity())
|
||||
spare_.swap(recycle->nv12);
|
||||
return result;
|
||||
}
|
||||
void LatestFrame::Reset(uint64_t generation) {
|
||||
std::lock_guard lock(mutex_);
|
||||
generation_ = generation;
|
||||
pending_.reset();
|
||||
spare_.clear();
|
||||
}
|
||||
nlohmann::json LatestFrame::Stats() const {
|
||||
std::lock_guard lock(mutex_);
|
||||
return {{"received", received_},
|
||||
{"replaced", dropped_},
|
||||
{"invalid", invalid_},
|
||||
{"pending", pending_ ? 1 : 0}};
|
||||
}
|
||||
nlohmann::json ParseBoundedJson(const std::string &text) {
|
||||
if (text.size() > 65536)
|
||||
return {};
|
||||
int depth = 0;
|
||||
bool quoted = false, escaped = false;
|
||||
for (char c : text) {
|
||||
if (quoted) {
|
||||
if (escaped)
|
||||
escaped = false;
|
||||
else if (c == '\\')
|
||||
escaped = true;
|
||||
else if (c == '"')
|
||||
quoted = false;
|
||||
} else if (c == '"')
|
||||
quoted = true;
|
||||
else if (c == '{' || c == '[') {
|
||||
if (++depth > 32)
|
||||
return {};
|
||||
} else if (c == '}' || c == ']') {
|
||||
if (--depth < 0)
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return nlohmann::json::parse(text, nullptr, false);
|
||||
}
|
||||
std::string SafeText(const std::string &text, size_t limit) {
|
||||
// ASCII metadata avoids escape injection, C1 controls and bidi spoofing.
|
||||
std::string out;
|
||||
for (unsigned char c : text) {
|
||||
if (out.size() == limit)
|
||||
break;
|
||||
out += c >= 32 && c < 127 ? char(c) : '?';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-30
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _TERMINAL_RENDERER_H_
|
||||
#define _TERMINAL_RENDERER_H_
|
||||
|
||||
#include <controller_session.h>
|
||||
namespace crossdesk::terminal {
|
||||
enum class AnsiDetail { Half, Quadrant };
|
||||
struct Viewport {
|
||||
int columns = 80, rows = 23;
|
||||
double cell_aspect = .5;
|
||||
bool utf8 = true;
|
||||
int colors = 24;
|
||||
AnsiDetail detail = AnsiDetail::Half;
|
||||
double zoom = 1, center_x = .5, center_y = .5;
|
||||
int pixel_width = 0, pixel_height = 0;
|
||||
bool native = false, pixel_mode = false;
|
||||
};
|
||||
struct Rect {
|
||||
int x, y, width, height;
|
||||
};
|
||||
struct Cell {
|
||||
uint32_t top = 0, bottom = 0;
|
||||
uint32_t glyph = 0x2580;
|
||||
bool operator==(const Cell &other) const {
|
||||
return top == other.top && bottom == other.bottom && glyph == other.glyph;
|
||||
}
|
||||
};
|
||||
struct Grid {
|
||||
Viewport viewport;
|
||||
Rect content{};
|
||||
std::vector<Cell> cells;
|
||||
Rect source{};
|
||||
unsigned source_width = 0, source_height = 0;
|
||||
int samples_x = 1, samples_y = 2;
|
||||
};
|
||||
Rect Fit(int width, int height, int columns, int pixel_rows,
|
||||
double pixel_aspect = 1);
|
||||
Grid Layout(const DecodedFrame &, Viewport);
|
||||
Grid Convert(const DecodedFrame &, Viewport);
|
||||
std::optional<std::pair<float, float>> MapPointer(const Grid &, int column,
|
||||
int row);
|
||||
std::vector<std::string> Lines(const Grid &);
|
||||
std::string Difference(const Grid &, const std::optional<Grid> &previous);
|
||||
std::string Kitty(const DecodedFrame &, const Viewport &);
|
||||
inline constexpr char kKittyQuery[] =
|
||||
"\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\";
|
||||
inline constexpr char kKittyDelete[] = "\033_Ga=d,d=I,i=31,q=2\033\\";
|
||||
bool KittyReply(const std::string &response);
|
||||
// Incremental parser: protocol replies and unknown escapes are consumed
|
||||
// locally. Key taps always release immediately; paste uses the UTF-8 text
|
||||
// protocol.
|
||||
class Input {
|
||||
public:
|
||||
void Feed(const std::string &bytes, ControllerSession &, const Grid *grid);
|
||||
bool exit = false, control = false, native = false;
|
||||
std::string error;
|
||||
double zoom = 1, center_x = .5, center_y = .5;
|
||||
uint64_t view_revision = 0;
|
||||
|
||||
private:
|
||||
std::string pending_;
|
||||
std::chrono::steady_clock::time_point last_input_{};
|
||||
bool pasting_ = false;
|
||||
float pointer_x_ = .5f, pointer_y_ = .5f;
|
||||
void Tap(ControllerSession &, int key, int modifier = 0);
|
||||
void Navigate(char key, const Grid *);
|
||||
};
|
||||
} // namespace crossdesk::terminal
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,255 @@
|
||||
#include "terminal_renderer.h"
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
namespace crossdesk::terminal {
|
||||
void Input::Tap(ControllerSession &s, int key, int modifier) {
|
||||
if (modifier)
|
||||
s.Key(modifier, true);
|
||||
s.Key(key, true);
|
||||
s.Key(key, false);
|
||||
if (modifier)
|
||||
s.Key(modifier, false);
|
||||
}
|
||||
void Input::Navigate(char key, const Grid *grid) {
|
||||
if (key == '+' || key == '=')
|
||||
zoom = std::min(32.0, zoom * 2);
|
||||
if (key == '-')
|
||||
zoom = std::max(1.0, zoom / 2);
|
||||
if (key == '0') {
|
||||
native = false;
|
||||
zoom = 1;
|
||||
center_x = center_y = .5;
|
||||
}
|
||||
if (key == '1') {
|
||||
native = true;
|
||||
zoom = 1;
|
||||
}
|
||||
// Use the displayed crop as the pan step, including native pixel mode.
|
||||
const double dx = grid && grid->source_width
|
||||
? double(grid->source.width) / grid->source_width
|
||||
: 1 / zoom;
|
||||
const double dy = grid && grid->source_height
|
||||
? double(grid->source.height) / grid->source_height
|
||||
: 1 / zoom;
|
||||
if (key == 'h' || key == 'j' || key == 'k' || key == 'l') {
|
||||
center_x = std::clamp(center_x, dx / 2, 1 - dx / 2);
|
||||
center_y = std::clamp(center_y, dy / 2, 1 - dy / 2);
|
||||
}
|
||||
if (key == 'h')
|
||||
center_x -= .2 * dx;
|
||||
if (key == 'l')
|
||||
center_x += .2 * dx;
|
||||
if (key == 'k')
|
||||
center_y -= .2 * dy;
|
||||
if (key == 'j')
|
||||
center_y += .2 * dy;
|
||||
center_x = std::clamp(center_x, 0.0, 1.0);
|
||||
center_y = std::clamp(center_y, 0.0, 1.0);
|
||||
++view_revision;
|
||||
}
|
||||
void Input::Feed(const std::string &bytes, ControllerSession &s,
|
||||
const Grid *grid) {
|
||||
if (pending_.size() + bytes.size() > 32768) {
|
||||
pending_.clear();
|
||||
pasting_ = false;
|
||||
s.SetControl(false);
|
||||
control = false;
|
||||
return;
|
||||
}
|
||||
if (!bytes.empty()) {
|
||||
last_input_ = std::chrono::steady_clock::now();
|
||||
error.clear();
|
||||
}
|
||||
if (bytes.empty() && pending_ == "\033" &&
|
||||
std::chrono::steady_clock::now() - last_input_ >
|
||||
std::chrono::milliseconds(80)) {
|
||||
Tap(s, 27);
|
||||
pending_.clear();
|
||||
return;
|
||||
}
|
||||
pending_ += bytes;
|
||||
while (!pending_.empty()) {
|
||||
if (pasting_) {
|
||||
auto end = pending_.find("\033[201~");
|
||||
if (end == std::string::npos)
|
||||
return; // bounded by 32 KiB; one UTF-8 transaction
|
||||
if (control && !s.Text(pending_.substr(0, end)))
|
||||
error = "Remote UTF-8 text input unavailable";
|
||||
pending_.erase(0, end + 6);
|
||||
pasting_ = false;
|
||||
continue;
|
||||
}
|
||||
unsigned char c = pending_[0];
|
||||
if (c == 29) {
|
||||
exit = true;
|
||||
pending_.erase(0, 1);
|
||||
s.SetControl(false);
|
||||
return;
|
||||
}
|
||||
if (c == 20) {
|
||||
control = !control;
|
||||
s.SetControl(control);
|
||||
pending_.erase(0, 1);
|
||||
continue;
|
||||
}
|
||||
if (!control && (c == '+' || c == '=' || c == '-' || c == '0' || c == '1' ||
|
||||
c == 'h' || c == 'j' || c == 'k' || c == 'l')) {
|
||||
Navigate(c, grid);
|
||||
pending_.erase(0, 1);
|
||||
continue;
|
||||
}
|
||||
if (c == 27) {
|
||||
if (pending_.size() < 2)
|
||||
return;
|
||||
if (pending_.compare(0, 6, "\033[200~") == 0) {
|
||||
pasting_ = true;
|
||||
pending_.erase(0, 6);
|
||||
continue;
|
||||
}
|
||||
if (pending_[1] == '_') {
|
||||
auto end = pending_.find("\033\\", 2);
|
||||
if (end == std::string::npos)
|
||||
return;
|
||||
pending_.erase(0, end + 2);
|
||||
continue;
|
||||
}
|
||||
if (pending_[1] == '[' || pending_[1] == 'O') {
|
||||
size_t end = 2;
|
||||
while (end < pending_.size() &&
|
||||
!(pending_[end] >= '@' && pending_[end] <= '~'))
|
||||
++end;
|
||||
if (end == pending_.size())
|
||||
return;
|
||||
std::string seq = pending_.substr(0, end + 1);
|
||||
pending_.erase(0, end + 1);
|
||||
if (seq == "\033[O") {
|
||||
control = false;
|
||||
s.SetControl(false);
|
||||
continue;
|
||||
}
|
||||
int key = 0;
|
||||
if (seq == "\033[A")
|
||||
key = 38;
|
||||
else if (seq == "\033[B")
|
||||
key = 40;
|
||||
else if (seq == "\033[C")
|
||||
key = 39;
|
||||
else if (seq == "\033[D")
|
||||
key = 37;
|
||||
else if (seq == "\033[H")
|
||||
key = 36;
|
||||
else if (seq == "\033[F")
|
||||
key = 35;
|
||||
else if (seq == "\033[3~")
|
||||
key = 46;
|
||||
else if (seq == "\033[5~")
|
||||
key = 33;
|
||||
else if (seq == "\033[6~")
|
||||
key = 34;
|
||||
else if (seq == "\033OP")
|
||||
key = 112;
|
||||
else if (seq == "\033OQ")
|
||||
key = 113;
|
||||
else if (seq == "\033OR")
|
||||
key = 114;
|
||||
else if (seq == "\033OS")
|
||||
key = 115;
|
||||
if (key) {
|
||||
if (!control && key >= 37 && key <= 40) {
|
||||
Navigate(key == 37 ? 'h'
|
||||
: key == 38 ? 'k'
|
||||
: key == 39 ? 'l'
|
||||
: 'j',
|
||||
grid);
|
||||
continue;
|
||||
}
|
||||
Tap(s, key);
|
||||
continue;
|
||||
}
|
||||
int b = 0, x = 0, y = 0;
|
||||
char action = seq.back();
|
||||
bool mouse =
|
||||
seq.rfind("\033[<", 0) == 0 && (action == 'M' || action == 'm');
|
||||
const char *cur = seq.data() + std::min(size_t(3), seq.size());
|
||||
const char *limit = seq.data() + seq.size() - 1;
|
||||
for (int *value : {&b, &x, &y}) {
|
||||
if (!mouse)
|
||||
break;
|
||||
auto result = std::from_chars(cur, limit, *value);
|
||||
mouse = result.ec == std::errc{} && *value >= 0 && *value <= 10000;
|
||||
cur = result.ptr;
|
||||
if (value != &y) {
|
||||
mouse = mouse && cur < limit && *cur == ';';
|
||||
if (mouse)
|
||||
++cur;
|
||||
} else
|
||||
mouse = mouse && cur == limit;
|
||||
}
|
||||
if (grid && mouse) {
|
||||
auto pos = MapPointer(*grid, x - 1, y - 1);
|
||||
if (!control) {
|
||||
if (pos && action == 'M' && ((b & 64) || b == 0)) {
|
||||
center_x = pos->first;
|
||||
center_y = pos->second;
|
||||
if (b & 64)
|
||||
Navigate((b & 1) ? '-' : '+', grid);
|
||||
else
|
||||
Navigate('1', grid);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!pos && action != 'm')
|
||||
continue;
|
||||
if (pos) {
|
||||
pointer_x_ = pos->first;
|
||||
pointer_y_ = pos->second;
|
||||
}
|
||||
int flag = 0, wheel = 0;
|
||||
if (b & 64) {
|
||||
flag = (b & 2) ? 8 : 7;
|
||||
wheel = (b & 1) ? -120 : 120;
|
||||
} else if (!(b & 32)) {
|
||||
int base = b & 3;
|
||||
if (base == 3)
|
||||
continue;
|
||||
flag = (base == 0 ? 1
|
||||
: base == 1 ? 5
|
||||
: 3) +
|
||||
(action == 'm' ? 1 : 0);
|
||||
}
|
||||
s.Pointer(pointer_x_, pointer_y_, flag, wheel);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Alt+ASCII letter: a tap with explicit modifier release.
|
||||
if (pending_[1] >= 'a' && pending_[1] <= 'z')
|
||||
Tap(s, pending_[1] - 'a' + 'A', 18);
|
||||
pending_.erase(0, 2);
|
||||
continue;
|
||||
}
|
||||
if (c < 32 || c == 127) {
|
||||
pending_.erase(0, 1);
|
||||
if (c == 13 || c == 10)
|
||||
Tap(s, 13);
|
||||
else if (c == 9)
|
||||
Tap(s, 9);
|
||||
else if (c == 127 || c == 8)
|
||||
Tap(s, 8);
|
||||
else if (c >= 1 && c <= 26)
|
||||
Tap(s, 'A' + c - 1, 17);
|
||||
continue;
|
||||
}
|
||||
size_t n = c < 128 ? 1
|
||||
: (c & 0xe0) == 0xc0 ? 2
|
||||
: (c & 0xf0) == 0xe0 ? 3
|
||||
: (c & 0xf8) == 0xf0 ? 4
|
||||
: 1;
|
||||
if (pending_.size() < n)
|
||||
return;
|
||||
auto text = pending_.substr(0, n);
|
||||
pending_.erase(0, n);
|
||||
if (control && !s.Text(text))
|
||||
error = "Remote UTF-8 text input unavailable";
|
||||
}
|
||||
}
|
||||
} // namespace crossdesk::terminal
|
||||
@@ -0,0 +1,341 @@
|
||||
#include "terminal_renderer.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <zlib.h>
|
||||
namespace crossdesk::terminal {
|
||||
Rect Fit(int w, int h, int cols, int rows, double aspect) {
|
||||
if (w <= 0 || h <= 0 || cols <= 0 || rows <= 0 || !std::isfinite(aspect) ||
|
||||
aspect <= 0)
|
||||
return {};
|
||||
const double scale = std::min(cols * aspect / w, double(rows) / h);
|
||||
const int cw = std::max(1, std::min(cols, int(w * scale / aspect)));
|
||||
const int ch = std::max(1, std::min(rows, int(h * scale)));
|
||||
return {(cols - cw) / 2, (rows - ch) / 2, cw, ch};
|
||||
}
|
||||
static uint32_t Pixel(const DecodedFrame &f, int x, int y) {
|
||||
const size_t offset =
|
||||
size_t(f.width) * f.height + (y / 2) * f.width + (x & ~1);
|
||||
int yy = std::max(0, int(f.nv12[size_t(y) * f.width + x]) - 16);
|
||||
int u = int(f.nv12[offset]) - 128, v = int(f.nv12[offset + 1]) - 128;
|
||||
auto clamp = [](int n) { return uint32_t(std::clamp(n, 0, 255)); };
|
||||
return (clamp((298 * yy + 409 * v + 128) >> 8) << 16) |
|
||||
(clamp((298 * yy - 100 * u - 208 * v + 128) >> 8) << 8) |
|
||||
clamp((298 * yy + 516 * u + 128) >> 8);
|
||||
}
|
||||
static void Validate(const DecodedFrame &f, const Viewport &v) {
|
||||
if (v.columns < 1 || v.rows < 1 || v.columns > 240 || v.rows > 100 ||
|
||||
!std::isfinite(v.cell_aspect) || v.cell_aspect < .2 ||
|
||||
v.cell_aspect > 2 || !std::isfinite(v.zoom) || v.zoom < 1 ||
|
||||
v.zoom > 32 || !std::isfinite(v.center_x) || !std::isfinite(v.center_y) ||
|
||||
v.center_x < 0 || v.center_x > 1 || v.center_y < 0 || v.center_y > 1 ||
|
||||
!f.width || !f.height || f.width > 8192 || f.height > 8192 ||
|
||||
(f.width & 1) || (f.height & 1) ||
|
||||
size_t(f.width) * f.height > 16777216 ||
|
||||
f.nv12.size() < size_t(f.width) * f.height * 3 / 2)
|
||||
throw std::invalid_argument("invalid frame or viewport");
|
||||
}
|
||||
static Rect Crop(const DecodedFrame &f, const Viewport &v) {
|
||||
double width = f.width, height = f.height;
|
||||
if (v.native) {
|
||||
const int sx = v.utf8 && v.detail == AnsiDetail::Quadrant ? 2 : 1;
|
||||
const int sy = v.utf8 ? 2 : 1;
|
||||
const int ow = v.pixel_mode ? std::clamp(v.pixel_width > 0 ? v.pixel_width
|
||||
: v.columns * 10,
|
||||
2, 1920)
|
||||
: v.columns * sx;
|
||||
const int oh = v.pixel_mode ? std::clamp(v.pixel_height > 0 ? v.pixel_height
|
||||
: v.rows * 20,
|
||||
2, 1080)
|
||||
: v.rows * sy;
|
||||
const double aspect =
|
||||
double(v.columns) * v.cell_aspect * oh / (v.rows * ow);
|
||||
// Fit square source pixels into non-square character samples without
|
||||
// downsampling either dimension. Native mode may show a small crop.
|
||||
width = std::min(width, std::floor(ow * std::min(1.0, aspect)));
|
||||
height = std::min(height, std::floor(oh * std::min(1.0, 1 / aspect)));
|
||||
}
|
||||
int w = std::max(1, int(width / v.zoom)),
|
||||
h = std::max(1, int(height / v.zoom));
|
||||
int x = std::clamp(int(v.center_x * f.width - w * .5), 0, int(f.width) - w);
|
||||
int y = std::clamp(int(v.center_y * f.height - h * .5), 0, int(f.height) - h);
|
||||
return {x, y, w, h};
|
||||
}
|
||||
// Area resampling integrates the entire source footprint. Thin lines cannot
|
||||
// disappear merely because a nearest-neighbour sample landed beside them.
|
||||
static uint32_t Sample(const DecodedFrame &f, double x0, double y0, double x1,
|
||||
double y1) {
|
||||
double sums[3] = {}, weight = 0;
|
||||
for (int y = int(std::floor(y0));
|
||||
y < std::min(int(f.height), int(std::ceil(y1))); ++y) {
|
||||
double wy = std::min(y1, double(y + 1)) - std::max(y0, double(y));
|
||||
for (int x = int(std::floor(x0));
|
||||
x < std::min(int(f.width), int(std::ceil(x1))); ++x) {
|
||||
double w = wy * (std::min(x1, double(x + 1)) - std::max(x0, double(x)));
|
||||
auto c = Pixel(f, std::max(0, x), std::max(0, y));
|
||||
sums[0] += (c >> 16) * w;
|
||||
sums[1] += ((c >> 8) & 255) * w;
|
||||
sums[2] += (c & 255) * w;
|
||||
weight += w;
|
||||
}
|
||||
}
|
||||
if (weight <= 0)
|
||||
return 0;
|
||||
auto round = [&](int i) {
|
||||
return uint32_t(std::clamp(int(std::lround(sums[i] / weight)), 0, 255));
|
||||
};
|
||||
return (round(0) << 16) | (round(1) << 8) | round(2);
|
||||
}
|
||||
static Cell Quadrant(const std::array<uint32_t, 4> &pixels) {
|
||||
// Enumerate all seven two-colour partitions (complements are equivalent).
|
||||
static constexpr uint32_t glyphs[16] = {
|
||||
0x20, 0x2598, 0x259D, 0x2580, 0x2596, 0x258C, 0x259E, 0x259B,
|
||||
0x2597, 0x259A, 0x2590, 0x259C, 0x2584, 0x2599, 0x259F, 0x2588};
|
||||
Cell best{};
|
||||
double best_error = 1e30;
|
||||
for (unsigned mask = 1; mask < 8; ++mask) {
|
||||
double sums[2][3] = {};
|
||||
unsigned count[2] = {};
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
int g = (mask >> i) & 1;
|
||||
++count[g];
|
||||
sums[g][0] += pixels[i] >> 16;
|
||||
sums[g][1] += (pixels[i] >> 8) & 255;
|
||||
sums[g][2] += pixels[i] & 255;
|
||||
}
|
||||
uint32_t colors[2] = {};
|
||||
double error = 0;
|
||||
for (int g = 0; g < 2; ++g)
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sums[g][c] = std::round(sums[g][c] / count[g]);
|
||||
colors[g] |= uint32_t(sums[g][c]) << (16 - 8 * c);
|
||||
}
|
||||
for (int i = 0; i < 4; ++i)
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
double d = double((pixels[i] >> (16 - 8 * c)) & 255) -
|
||||
sums[(mask >> i) & 1][c];
|
||||
error += d * d;
|
||||
}
|
||||
if (error < best_error) {
|
||||
best_error = error;
|
||||
best = {colors[1], colors[0], glyphs[mask]};
|
||||
}
|
||||
}
|
||||
if (best.top == best.bottom)
|
||||
best.glyph = 0x20;
|
||||
return best;
|
||||
}
|
||||
static std::string EncodeUtf8(uint32_t c) {
|
||||
if (c < 128)
|
||||
return std::string(1, char(c));
|
||||
if (c < 2048)
|
||||
return {char(0xc0 | (c >> 6)), char(0x80 | (c & 63))};
|
||||
return {char(0xe0 | (c >> 12)), char(0x80 | ((c >> 6) & 63)),
|
||||
char(0x80 | (c & 63))};
|
||||
}
|
||||
Grid Layout(const DecodedFrame &f, Viewport v) {
|
||||
Validate(f, v);
|
||||
const int sx = v.utf8 && v.detail == AnsiDetail::Quadrant ? 2 : 1,
|
||||
sy = v.utf8 ? 2 : 1;
|
||||
const int width = v.columns * sx, height = v.rows * sy;
|
||||
Rect crop = Crop(f, v);
|
||||
Grid g{v,
|
||||
Fit(crop.width, crop.height, width, height, v.cell_aspect * sy / sx),
|
||||
{}};
|
||||
g.source = crop;
|
||||
g.source_width = f.width;
|
||||
g.source_height = f.height;
|
||||
g.samples_x = sx;
|
||||
g.samples_y = sy;
|
||||
return g;
|
||||
}
|
||||
Grid Convert(const DecodedFrame &f, Viewport v) {
|
||||
auto g = Layout(f, v);
|
||||
const auto crop = g.source;
|
||||
const int sx = g.samples_x, sy = g.samples_y;
|
||||
g.cells.resize(size_t(v.columns) * v.rows);
|
||||
auto pixel = [&](int x, int y) {
|
||||
const auto &r = g.content;
|
||||
if (x < r.x || y < r.y || x >= r.x + r.width || y >= r.y + r.height)
|
||||
return uint32_t(0);
|
||||
double x0 = crop.x + double(x - r.x) * crop.width / r.width;
|
||||
double y0 = crop.y + double(y - r.y) * crop.height / r.height;
|
||||
return Sample(f, x0, y0, x0 + double(crop.width) / r.width,
|
||||
y0 + double(crop.height) / r.height);
|
||||
};
|
||||
for (int y = 0; y < v.rows; ++y)
|
||||
for (int x = 0; x < v.columns; ++x) {
|
||||
auto &c = g.cells[size_t(y) * v.columns + x];
|
||||
if (sx == 2)
|
||||
c = Quadrant({pixel(x * 2, y * 2), pixel(x * 2 + 1, y * 2),
|
||||
pixel(x * 2, y * 2 + 1), pixel(x * 2 + 1, y * 2 + 1)});
|
||||
else {
|
||||
c.top = pixel(x, y * sy);
|
||||
c.bottom = sy == 1 ? c.top : pixel(x, y * sy + sy - 1);
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
std::optional<std::pair<float, float>> MapPointer(const Grid &g, int x, int y) {
|
||||
x *= g.samples_x;
|
||||
y *= g.samples_y;
|
||||
const auto &r = g.content;
|
||||
if (!r.width || !r.height || !g.source_width || !g.source_height || x < r.x ||
|
||||
y < r.y || x >= r.x + r.width || y >= r.y + r.height)
|
||||
return {};
|
||||
float fx = float(x - r.x) / std::max(1, r.width - 1),
|
||||
fy = float(y - r.y) / std::max(1, r.height - 1);
|
||||
return std::pair<float, float>{
|
||||
(g.source.x + fx * std::max(0, g.source.width - 1)) /
|
||||
std::max(1u, g.source_width - 1),
|
||||
(g.source.y + fy * std::max(0, g.source.height - 1)) /
|
||||
std::max(1u, g.source_height - 1)};
|
||||
}
|
||||
static std::string Color(uint32_t c, bool fg, int depth) {
|
||||
const int r = c >> 16, g = (c >> 8) & 255, b = c & 255;
|
||||
const std::string prefix = fg ? "\033[38;" : "\033[48;";
|
||||
if (depth == 24)
|
||||
return prefix + "2;" + std::to_string(r) + ";" + std::to_string(g) + ";" +
|
||||
std::to_string(b) + "m";
|
||||
if (depth == 256) {
|
||||
constexpr int levels[] = {0, 95, 135, 175, 215, 255};
|
||||
auto index = [&](int value) {
|
||||
int best = 0;
|
||||
for (int i = 1; i < 6; ++i)
|
||||
if (std::abs(levels[i] - value) < std::abs(levels[best] - value))
|
||||
best = i;
|
||||
return best;
|
||||
};
|
||||
int ri = index(r), gi = index(g), bi = index(b);
|
||||
int cube = (r - levels[ri]) * (r - levels[ri]) +
|
||||
(g - levels[gi]) * (g - levels[gi]) +
|
||||
(b - levels[bi]) * (b - levels[bi]);
|
||||
int gray =
|
||||
std::clamp(int(std::lround(((r + g + b) / 3.0 - 8) / 10)), 0, 23),
|
||||
gv = 8 + 10 * gray;
|
||||
int gray_error =
|
||||
(r - gv) * (r - gv) + (g - gv) * (g - gv) + (b - gv) * (b - gv);
|
||||
return prefix + "5;" +
|
||||
std::to_string(gray_error < cube ? 232 + gray
|
||||
: 16 + 36 * ri + 6 * gi + bi) +
|
||||
"m";
|
||||
}
|
||||
int i = (r >= 128 ? 1 : 0) + (g >= 128 ? 2 : 0) + (b >= 128 ? 4 : 0);
|
||||
return "\033[" + std::to_string((fg ? 30 : 40) + i) + "m";
|
||||
}
|
||||
static std::string Line(const Grid &g, int y) {
|
||||
std::string line;
|
||||
uint32_t fg = UINT32_MAX, bg = UINT32_MAX;
|
||||
for (int x = 0; x < g.viewport.columns; ++x) {
|
||||
const auto &c = g.cells[size_t(y) * g.viewport.columns + x];
|
||||
if (g.viewport.utf8 && fg != c.top) {
|
||||
line += Color(c.top, true, g.viewport.colors);
|
||||
fg = c.top;
|
||||
}
|
||||
const auto bottom = g.viewport.utf8 ? c.bottom : c.top;
|
||||
if (bg != bottom) {
|
||||
line += Color(bottom, false, g.viewport.colors);
|
||||
bg = bottom;
|
||||
}
|
||||
line += g.viewport.utf8 ? EncodeUtf8(c.glyph) : " ";
|
||||
}
|
||||
return line + "\033[0m";
|
||||
}
|
||||
std::vector<std::string> Lines(const Grid &g) {
|
||||
std::vector<std::string> lines;
|
||||
lines.reserve(g.viewport.rows);
|
||||
for (int y = 0; y < g.viewport.rows; ++y)
|
||||
lines.push_back(Line(g, y));
|
||||
return lines;
|
||||
}
|
||||
std::string Difference(const Grid &g, const std::optional<Grid> &previous) {
|
||||
std::string out;
|
||||
const bool same = previous &&
|
||||
previous->viewport.columns == g.viewport.columns &&
|
||||
previous->viewport.rows == g.viewport.rows &&
|
||||
previous->viewport.colors == g.viewport.colors &&
|
||||
previous->viewport.utf8 == g.viewport.utf8;
|
||||
for (int y = 0; y < g.viewport.rows; ++y) {
|
||||
auto start = g.cells.begin() + size_t(y) * g.viewport.columns;
|
||||
if (!same ||
|
||||
!std::equal(start, start + g.viewport.columns,
|
||||
previous->cells.begin() + size_t(y) * g.viewport.columns))
|
||||
out += "\033[" + std::to_string(y + 1) + ";1H" + Line(g, y);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
static std::string Base64(const std::vector<uint8_t> &data) {
|
||||
constexpr char table[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve((data.size() + 2) / 3 * 4);
|
||||
for (size_t i = 0; i < data.size(); i += 3) {
|
||||
unsigned n = unsigned(data[i]) << 16;
|
||||
if (i + 1 < data.size())
|
||||
n |= unsigned(data[i + 1]) << 8;
|
||||
if (i + 2 < data.size())
|
||||
n |= data[i + 2];
|
||||
out += table[n >> 18];
|
||||
out += table[(n >> 12) & 63];
|
||||
out += i + 1 < data.size() ? table[(n >> 6) & 63] : '=';
|
||||
out += i + 2 < data.size() ? table[n & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
std::string Kitty(const DecodedFrame &f, const Viewport &requested) {
|
||||
auto v = requested;
|
||||
v.pixel_mode = true;
|
||||
Validate(f, v);
|
||||
// Use reported terminal pixel dimensions; otherwise a conservative 10x20
|
||||
// cell.
|
||||
int w =
|
||||
std::clamp(v.pixel_width > 0 ? v.pixel_width : v.columns * 10, 2, 1920);
|
||||
int h =
|
||||
std::clamp(v.pixel_height > 0 ? v.pixel_height : v.rows * 20, 2, 1080);
|
||||
const auto crop = Crop(f, v);
|
||||
std::string b64;
|
||||
for (;;) {
|
||||
auto r = Fit(crop.width, crop.height, w, h,
|
||||
double(v.columns) * v.cell_aspect * h / (v.rows * w));
|
||||
std::vector<uint8_t> rgb(size_t(w) * h * 3, 0);
|
||||
for (int y = 0; y < r.height; ++y)
|
||||
for (int x = 0; x < r.width; ++x) {
|
||||
double x0 = crop.x + double(x) * crop.width / r.width,
|
||||
y0 = crop.y + double(y) * crop.height / r.height;
|
||||
auto c = Sample(f, x0, y0, x0 + double(crop.width) / r.width,
|
||||
y0 + double(crop.height) / r.height);
|
||||
auto i = (size_t(y + r.y) * w + x + r.x) * 3;
|
||||
rgb[i] = c >> 16;
|
||||
rgb[i + 1] = c >> 8;
|
||||
rgb[i + 2] = c;
|
||||
}
|
||||
uLongf length = compressBound(rgb.size());
|
||||
std::vector<uint8_t> compressed(length);
|
||||
if (compress2(compressed.data(), &length, rgb.data(), rgb.size(),
|
||||
Z_BEST_SPEED) != Z_OK)
|
||||
throw std::runtime_error("pixel compression failed");
|
||||
compressed.resize(length);
|
||||
b64 = Base64(compressed);
|
||||
// Reserve space for protocol chunk headers; keep the existing 2 MiB cap.
|
||||
if (b64.size() < 1900000)
|
||||
break;
|
||||
w = std::max(2, w * 3 / 4);
|
||||
h = std::max(2, h * 3 / 4);
|
||||
}
|
||||
std::string out = "\033[H";
|
||||
for (size_t i = 0; i < b64.size(); i += 4096) {
|
||||
out += "\033_G";
|
||||
if (i == 0)
|
||||
out += "a=T,t=d,f=24,o=z,q=2,i=31,p=1,C=1,s=" + std::to_string(w) +
|
||||
",v=" + std::to_string(h) + ",c=" + std::to_string(v.columns) +
|
||||
",r=" + std::to_string(v.rows) + ",";
|
||||
out += "m=" + std::to_string(i + 4096 < b64.size() ? 1 : 0) + ";" +
|
||||
b64.substr(i, 4096) + "\033\\";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
bool KittyReply(const std::string &s) {
|
||||
return s.find("\033_Gi=31;OK\033\\") != std::string::npos;
|
||||
}
|
||||
} // namespace crossdesk::terminal
|
||||
Reference in New Issue
Block a user