Add fork features: host view sync, grouping, image compress, freistellen
Host can force all clients to their camera; selection Group/Ungroup with synced groupIds; WebP/PNG preprocess and Tower rembg/white-key freistellen on insert or via selection actions (save format 0.7 / INFPNT000007). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
df49687f51
commit
042fe8f5a6
19 changed files with 672 additions and 9 deletions
|
|
@ -49,7 +49,7 @@ CanvasComponentContainer::CanvasComponentContainer(NetworkingObjects::NetObjMana
|
|||
|
||||
void CanvasComponentContainer::register_class(World& w) {
|
||||
auto readConstructorFunc = [&w](const NetworkingObjects::NetObjTemporaryPtr<CanvasComponentContainer>& o, cereal::PortableBinaryInputArchive& a, const std::shared_ptr<NetServer::ClientData>& c) {
|
||||
a(o->coords);
|
||||
a(o->coords, o->groupId);
|
||||
o->compAllocator = o.get_obj_man()->read_create_message<CanvasComponentAllocator>(a, c);
|
||||
o->compAllocator->comp->compContainer = o.get();
|
||||
canvas_scale_up_check(*o, w, c);
|
||||
|
|
@ -72,6 +72,7 @@ std::unique_ptr<CanvasComponentContainer::CopyData> CanvasComponentContainer::ge
|
|||
auto toRet = std::make_unique<CanvasComponentContainer::CopyData>();
|
||||
toRet->coords = coords;
|
||||
toRet->obj = compAllocator->comp->get_data_copy();
|
||||
// groupId intentionally not copied — paste creates independent objects
|
||||
return toRet;
|
||||
}
|
||||
|
||||
|
|
@ -84,18 +85,22 @@ void CanvasComponentContainer::set_object_update_lock(DrawingProgram& drawP, boo
|
|||
}
|
||||
|
||||
void CanvasComponentContainer::write_constructor_func(const NetworkingObjects::NetObjTemporaryPtr<CanvasComponentContainer>& o, cereal::PortableBinaryOutputArchive& a) {
|
||||
a(o->coords);
|
||||
a(o->coords, o->groupId);
|
||||
o->compAllocator.write_create_message(a);
|
||||
}
|
||||
|
||||
void CanvasComponentContainer::save_file(cereal::PortableBinaryOutputArchive& a) const {
|
||||
a(coords);
|
||||
a(coords, groupId);
|
||||
compAllocator->save_file(a);
|
||||
}
|
||||
|
||||
void CanvasComponentContainer::load_file(cereal::PortableBinaryInputArchive& a, VersionNumber version, NetObjManager& objMan) {
|
||||
if(version >= VersionNumber(0, 4, 0)) {
|
||||
a(coords);
|
||||
if(version >= VersionNumber(0, 7, 0))
|
||||
a(groupId);
|
||||
else
|
||||
groupId = 0;
|
||||
compAllocator = objMan.make_obj_direct<CanvasComponentAllocator>();
|
||||
compAllocator->load_file(a, version);
|
||||
compAllocator->comp->compContainer = this;
|
||||
|
|
@ -104,6 +109,7 @@ void CanvasComponentContainer::load_file(cereal::PortableBinaryInputArchive& a,
|
|||
CanvasComponentType t;
|
||||
NetworkingObjects::NetObjID uselessID;
|
||||
a(t, uselessID, coords);
|
||||
groupId = 0;
|
||||
compAllocator = objMan.make_obj_direct<CanvasComponentAllocator>(t);
|
||||
compAllocator->comp->load_file(a, version);
|
||||
compAllocator->comp->compContainer = this;
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ class CanvasComponentContainer {
|
|||
std::weak_ptr<DrawingProgramCacheBVHNode> cacheParentBvhNode;
|
||||
DrawingProgramLayerListItem* parentLayer = nullptr;
|
||||
CoordSpaceHelper coords;
|
||||
// 0 = ungrouped. Non-zero IDs move/transform together when any member is selected.
|
||||
uint64_t groupId = 0;
|
||||
ObjInfoIterator objInfo;
|
||||
private:
|
||||
friend class BrushStrokeCanvasComponent;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ enum ServerCommands : MessageCommandType {
|
|||
SERVER_UPDATE_NETWORK_OBJECT,
|
||||
SERVER_NEW_RESOURCE_ID,
|
||||
SERVER_NEW_RESOURCE_DATA,
|
||||
SERVER_TRANSFORM_MANY_COMPONENTS
|
||||
SERVER_TRANSFORM_MANY_COMPONENTS,
|
||||
SERVER_FORCE_CAMERA_JUMP,
|
||||
SERVER_SET_COMPONENT_GROUP_IDS
|
||||
};
|
||||
|
||||
enum ClientCommands : MessageCommandType {
|
||||
|
|
@ -39,5 +41,7 @@ enum ClientCommands : MessageCommandType {
|
|||
CLIENT_UPDATE_NETWORK_OBJECT,
|
||||
CLIENT_NEW_RESOURCE_ID,
|
||||
CLIENT_NEW_RESOURCE_DATA,
|
||||
CLIENT_TRANSFORM_MANY_COMPONENTS
|
||||
CLIENT_TRANSFORM_MANY_COMPONENTS,
|
||||
CLIENT_FORCE_CAMERA_JUMP,
|
||||
CLIENT_SET_COMPONENT_GROUP_IDS
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@
|
|||
#include <chrono>
|
||||
#include "../CanvasComponents/ImageCanvasComponent.hpp"
|
||||
#include "Layers/DrawingProgramLayer.hpp"
|
||||
#include "../ImagePreprocess.hpp"
|
||||
#include <Helpers/Random.hpp>
|
||||
#include <cereal/types/utility.hpp>
|
||||
#ifdef __ANDROID__
|
||||
#include "../AndroidJNICalls.hpp"
|
||||
#endif
|
||||
#include "Layers/DrawingProgramLayerListItem.hpp"
|
||||
#include "../ScaleUpCanvas.hpp"
|
||||
|
||||
|
|
@ -336,6 +342,22 @@ void DrawingProgram::init_server_callbacks() {
|
|||
process_transform_message(transforms);
|
||||
world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_TRANSFORM_MANY_COMPONENTS, transforms);
|
||||
});
|
||||
world.netServer->add_recv_callback(SERVER_FORCE_CAMERA_JUMP, [&](std::shared_ptr<NetServer::ClientData> client, cereal::PortableBinaryInputArchive& message) {
|
||||
CoordSpaceHelper coords;
|
||||
Vector2f windowSize;
|
||||
message(coords, windowSize);
|
||||
// Only the host may force jumps; ignore client-originated requests.
|
||||
(void)client;
|
||||
(void)coords;
|
||||
(void)windowSize;
|
||||
});
|
||||
world.netServer->add_recv_callback(SERVER_SET_COMPONENT_GROUP_IDS, [&](std::shared_ptr<NetServer::ClientData> client, cereal::PortableBinaryInputArchive& message) {
|
||||
std::vector<std::pair<NetworkingObjects::NetObjID, uint64_t>> groupIds;
|
||||
message(groupIds);
|
||||
(void)client;
|
||||
process_group_id_message(groupIds);
|
||||
world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_SET_COMPONENT_GROUP_IDS, groupIds);
|
||||
});
|
||||
}
|
||||
|
||||
void DrawingProgram::init_client_callbacks() {
|
||||
|
|
@ -344,6 +366,51 @@ void DrawingProgram::init_client_callbacks() {
|
|||
message(transforms);
|
||||
process_transform_message(transforms);
|
||||
});
|
||||
world.netClient->add_recv_callback(CLIENT_FORCE_CAMERA_JUMP, [&](cereal::PortableBinaryInputArchive& message) {
|
||||
CoordSpaceHelper coords;
|
||||
Vector2f windowSize;
|
||||
message(coords, windowSize);
|
||||
process_force_camera_jump(coords, windowSize);
|
||||
});
|
||||
world.netClient->add_recv_callback(CLIENT_SET_COMPONENT_GROUP_IDS, [&](cereal::PortableBinaryInputArchive& message) {
|
||||
std::vector<std::pair<NetworkingObjects::NetObjID, uint64_t>> groupIds;
|
||||
message(groupIds);
|
||||
process_group_id_message(groupIds);
|
||||
});
|
||||
}
|
||||
|
||||
void DrawingProgram::process_force_camera_jump(const CoordSpaceHelper& coords, const Vector2f& windowSize) {
|
||||
world.drawData.cam.smooth_move_to(world, coords, windowSize);
|
||||
}
|
||||
|
||||
void DrawingProgram::force_clients_to_own_view() {
|
||||
if(!world.netServer)
|
||||
return;
|
||||
CoordSpaceHelper coords = world.drawData.cam.c;
|
||||
Vector2f windowSize = world.main.window.size.cast<float>().eval();
|
||||
world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_FORCE_CAMERA_JUMP, coords, windowSize);
|
||||
}
|
||||
|
||||
void DrawingProgram::process_group_id_message(const std::vector<std::pair<NetworkingObjects::NetObjID, uint64_t>>& groupIds) {
|
||||
for(auto& [id, groupId] : groupIds) {
|
||||
auto objPtr = world.netObjMan.get_obj_temporary_ref_from_id<CanvasComponentContainer>(id);
|
||||
if(!objPtr)
|
||||
continue;
|
||||
objPtr->groupId = groupId;
|
||||
}
|
||||
world.set_to_layout_gui_if_focus();
|
||||
}
|
||||
|
||||
void DrawingProgram::send_group_ids_for(const std::vector<CanvasComponentContainer::ObjInfo*>& objs) {
|
||||
if(!world.netObjMan.is_connected() || objs.empty())
|
||||
return;
|
||||
std::vector<std::pair<NetworkingObjects::NetObjID, uint64_t>> groupIds;
|
||||
for(auto& obj : objs)
|
||||
groupIds.emplace_back(obj->obj.get_net_id(), obj->obj->groupId);
|
||||
if(world.netObjMan.is_server())
|
||||
world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_SET_COMPONENT_GROUP_IDS, groupIds);
|
||||
else
|
||||
world.netClient->send_items_to_server(RELIABLE_COMMAND_CHANNEL, SERVER_SET_COMPONENT_GROUP_IDS, groupIds);
|
||||
}
|
||||
|
||||
void DrawingProgram::process_transform_message(const std::vector<std::pair<NetworkingObjects::NetObjID, CoordSpaceHelper>>& transforms) {
|
||||
|
|
@ -726,7 +793,23 @@ void DrawingProgram::input_add_file_to_canvas_callback(const CustomEvents::AddFi
|
|||
|
||||
void DrawingProgram::add_file_to_canvas_by_path(const std::filesystem::path& filePath, Vector2f dropPos) {
|
||||
if(layerMan.is_a_layer_being_edited()) {
|
||||
NetworkingObjects::NetObjTemporaryPtr<ResourceData> imageTempPtr = world.rMan.add_resource_file(filePath);
|
||||
ResourceData resource;
|
||||
#ifdef __ANDROID__
|
||||
resource.name = AndroidJNICalls::getFileNameFromURI(filePath.string());
|
||||
if(resource.name.empty())
|
||||
resource.name = "New Resource";
|
||||
#else
|
||||
resource.name = std::filesystem::path(filePath).filename().string();
|
||||
#endif
|
||||
try {
|
||||
resource.data = std::make_shared<std::string>(read_file_to_string(filePath));
|
||||
}
|
||||
catch(...) {
|
||||
Logger::get().log(Logger::LogType::INFO, "[DrawingProgram::add_file_to_canvas_by_path] Could not open file " + filePath.string());
|
||||
return;
|
||||
}
|
||||
ImagePreprocess::preprocess_resource(resource.name, resource.data, ImagePreprocess::options_from_config(world.main.conf));
|
||||
NetworkingObjects::NetObjTemporaryPtr<ResourceData> imageTempPtr = world.rMan.add_resource(resource);
|
||||
if(imageTempPtr) {
|
||||
NetworkingObjects::NetObjID imageID = imageTempPtr.get_net_id();
|
||||
ResourceDisplay* display = world.rMan.get_display_data(imageID);
|
||||
|
|
@ -753,6 +836,7 @@ CanvasComponentContainer::ObjInfo* DrawingProgram::add_file_to_canvas_by_data(co
|
|||
ResourceData newResource;
|
||||
newResource.data = std::make_shared<std::string>(fileBuffer);
|
||||
newResource.name = fileName;
|
||||
ImagePreprocess::preprocess_resource(newResource.name, newResource.data, ImagePreprocess::options_from_config(world.main.conf));
|
||||
NetworkingObjects::NetObjID imageID = world.rMan.add_resource(newResource).get_net_id();
|
||||
ResourceDisplay* display = world.rMan.get_display_data(imageID);
|
||||
Vector2f imTrueDim = display->get_dimensions();
|
||||
|
|
@ -772,6 +856,32 @@ CanvasComponentContainer::ObjInfo* DrawingProgram::add_file_to_canvas_by_data(co
|
|||
return nullptr;
|
||||
}
|
||||
|
||||
void DrawingProgram::process_selected_images(bool freistellen, bool compress) {
|
||||
auto selected = selection.get_selection_as_set();
|
||||
ImagePreprocess::Options opts = ImagePreprocess::options_from_config(world.main.conf);
|
||||
opts.freistellenOnInsert = freistellen;
|
||||
opts.compressOnInsert = compress;
|
||||
if(!freistellen && !compress)
|
||||
return;
|
||||
for(auto* objInfo : selected) {
|
||||
if(objInfo->obj->get_comp().get_type() != CanvasComponentType::IMAGE)
|
||||
continue;
|
||||
auto& img = static_cast<ImageCanvasComponent&>(objInfo->obj->get_comp());
|
||||
auto resource = world.netObjMan.get_obj_temporary_ref_from_id<ResourceData>(img.d.imageID);
|
||||
if(!resource || !resource->data)
|
||||
continue;
|
||||
ResourceData newRes;
|
||||
newRes.name = resource->name;
|
||||
newRes.data = std::make_shared<std::string>(*resource->data);
|
||||
if(!ImagePreprocess::preprocess_resource(newRes.name, newRes.data, opts))
|
||||
continue;
|
||||
img.d.imageID = world.rMan.add_resource(newRes).get_net_id();
|
||||
objInfo->obj->send_comp_update(*this, true);
|
||||
objInfo->obj->commit_update(*this);
|
||||
}
|
||||
world.rMan.clear_display_cache();
|
||||
}
|
||||
|
||||
float DrawingProgram::drag_point_radius() {
|
||||
return (world.main.g.gui.io.isTouchDevice ? 10.0f : 8.0f) * world.main.g.final_gui_scale();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,9 @@ class DrawingProgram {
|
|||
void invalidate_cache_at_component(CanvasComponentContainer::ObjInfo* objToCheck);
|
||||
void preupdate_component(CanvasComponentContainer::ObjInfo* objToCheck);
|
||||
void send_transforms_for(const std::vector<CanvasComponentContainer::ObjInfo*>& objsToSendTransformsFor);
|
||||
void send_group_ids_for(const std::vector<CanvasComponentContainer::ObjInfo*>& objs);
|
||||
void force_clients_to_own_view();
|
||||
void process_selected_images(bool freistellen, bool compress);
|
||||
|
||||
void on_tab_out();
|
||||
void input_add_file_to_canvas_callback(const CustomEvents::AddFileToCanvasEvent& addFile);
|
||||
|
|
@ -105,6 +108,8 @@ class DrawingProgram {
|
|||
std::unique_ptr<DrawingProgramToolBase> drawTool;
|
||||
private:
|
||||
void process_transform_message(const std::vector<std::pair<NetworkingObjects::NetObjID, CoordSpaceHelper>>& transforms);
|
||||
void process_group_id_message(const std::vector<std::pair<NetworkingObjects::NetObjID, uint64_t>>& groupIds);
|
||||
void process_force_camera_jump(const CoordSpaceHelper& coords, const Vector2f& windowSize);
|
||||
|
||||
void drag_drop_update();
|
||||
void check_updateable_components();
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@
|
|||
#include "../GUIStuff/ElementHelpers/RadioButtonHelpers.hpp"
|
||||
#include "../GUIStuff/ElementHelpers/LayoutHelpers.hpp"
|
||||
#include "../GUIStuff/ElementHelpers/ButtonHelpers.hpp"
|
||||
#include <Helpers/Random.hpp>
|
||||
#include <limits>
|
||||
#include <unordered_set>
|
||||
|
||||
DrawingProgramSelection::DrawingProgramSelection(DrawingProgram& initDrawP):
|
||||
drawP(initDrawP)
|
||||
|
|
@ -75,6 +78,22 @@ void DrawingProgramSelection::selection_gui(Toolbar& t) {
|
|||
});
|
||||
text_label(gui, "Stroke Color");
|
||||
});
|
||||
left_to_right_line_layout(gui, [&]() {
|
||||
text_button(gui, "group selection", "Group", {
|
||||
.onClick = [&] { group_selection(); }
|
||||
});
|
||||
text_button(gui, "ungroup selection", "Ungroup", {
|
||||
.onClick = [&] { ungroup_selection(); }
|
||||
});
|
||||
});
|
||||
left_to_right_line_layout(gui, [&]() {
|
||||
text_button(gui, "compress selection images", "Compress Images", {
|
||||
.onClick = [&] { drawP.process_selected_images(false, true); }
|
||||
});
|
||||
text_button(gui, "freistellen selection images", "Freistellen", {
|
||||
.onClick = [&] { drawP.process_selected_images(true, true); }
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -327,14 +346,50 @@ std::function<bool(const std::shared_ptr<DrawingProgramCacheBVHNode>&)> DrawingP
|
|||
return toRet;
|
||||
}
|
||||
|
||||
void DrawingProgramSelection::expand_selection_with_groups() {
|
||||
std::unordered_set<uint64_t> groupIds;
|
||||
for(auto& c : selectedSet) {
|
||||
if(c->obj->groupId != 0)
|
||||
groupIds.insert(c->obj->groupId);
|
||||
}
|
||||
if(groupIds.empty())
|
||||
return;
|
||||
std::unordered_set<CanvasComponentContainer::ObjInfo*> already(selectedSet.begin(), selectedSet.end());
|
||||
for(auto* c : drawP.layerMan.get_flattened_component_list()) {
|
||||
if(c->obj->groupId != 0 && groupIds.contains(c->obj->groupId) && !already.contains(c)) {
|
||||
selectedSet.push_back(c);
|
||||
already.insert(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DrawingProgramSelection::group_selection() {
|
||||
if(selectedSet.size() < 2)
|
||||
return;
|
||||
uint64_t newGroupId = Random::get().int_range<uint64_t>(1, std::numeric_limits<uint64_t>::max());
|
||||
for(auto& c : selectedSet)
|
||||
c->obj->groupId = newGroupId;
|
||||
drawP.send_group_ids_for(selectedSet);
|
||||
}
|
||||
|
||||
void DrawingProgramSelection::ungroup_selection() {
|
||||
if(selectedSet.empty())
|
||||
return;
|
||||
for(auto& c : selectedSet)
|
||||
c->obj->groupId = 0;
|
||||
drawP.send_group_ids_for(selectedSet);
|
||||
}
|
||||
|
||||
void DrawingProgramSelection::add_to_selection(const std::vector<CanvasComponentContainer::ObjInfo*>& newSelection) {
|
||||
selectedSet.insert(selectedSet.end(), newSelection.begin(), newSelection.end());
|
||||
expand_selection_with_groups();
|
||||
sort_selection();
|
||||
calculate_aabb();
|
||||
}
|
||||
|
||||
void DrawingProgramSelection::set_to_selection(const std::vector<CanvasComponentContainer::ObjInfo*>& newSelection) {
|
||||
selectedSet = newSelection;
|
||||
expand_selection_with_groups();
|
||||
sort_selection();
|
||||
calculate_aabb();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ class DrawingProgramSelection {
|
|||
|
||||
void set_to_selection(const std::vector<CanvasComponentContainer::ObjInfo*>& newSelection);
|
||||
void add_to_selection(const std::vector<CanvasComponentContainer::ObjInfo*>& newSelection);
|
||||
void expand_selection_with_groups();
|
||||
void group_selection();
|
||||
void ungroup_selection();
|
||||
void calculate_aabb();
|
||||
void reset_all();
|
||||
void reset_transform_data();
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@ nlohmann::json GlobalConfig::get_config_json(const InputManager& input) const {
|
|||
toRet["flipZoomToolDirection"] = flipZoomToolDirection;
|
||||
toRet["realTimeEraser"] = realTimeEraser;
|
||||
toRet["disableTouchForDrawing"] = disableTouchForDrawing;
|
||||
toRet["imageCompressOnInsert"] = imageCompressOnInsert;
|
||||
toRet["imageCompressFormat"] = imageCompressFormat;
|
||||
toRet["imageWebpQuality"] = imageWebpQuality;
|
||||
toRet["imageFreistellenOnInsert"] = imageFreistellenOnInsert;
|
||||
toRet["imageFreistellenMode"] = imageFreistellenMode;
|
||||
toRet["imageWhiteKeyThreshold"] = imageWhiteKeyThreshold;
|
||||
#ifndef __EMSCRIPTEN__
|
||||
toRet["checkForUpdates"] = checkForUpdates;
|
||||
#endif
|
||||
|
|
@ -147,6 +153,12 @@ void GlobalConfig::set_config_json(InputManager& input, const nlohmann::json& j,
|
|||
try{j.at("defaultCanvasBackgroundColor").get_to(defaultCanvasBackgroundColor);} catch(...) {}
|
||||
try{j.at("flipZoomToolDirection").get_to(flipZoomToolDirection);} catch(...) {}
|
||||
try{j.at("realTimeEraser").get_to(realTimeEraser);} catch(...) {}
|
||||
try{j.at("imageCompressOnInsert").get_to(imageCompressOnInsert);} catch(...) {}
|
||||
try{j.at("imageCompressFormat").get_to(imageCompressFormat);} catch(...) {}
|
||||
try{j.at("imageWebpQuality").get_to(imageWebpQuality);} catch(...) {}
|
||||
try{j.at("imageFreistellenOnInsert").get_to(imageFreistellenOnInsert);} catch(...) {}
|
||||
try{j.at("imageFreistellenMode").get_to(imageFreistellenMode);} catch(...) {}
|
||||
try{j.at("imageWhiteKeyThreshold").get_to(imageWhiteKeyThreshold);} catch(...) {}
|
||||
#ifndef __EMSCRIPTEN__
|
||||
try{j.at("checkForUpdates").get_to(checkForUpdates);} catch(...) {}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
#include <nlohmann/json.hpp>
|
||||
#include <Helpers/VersionNumber.hpp>
|
||||
#include "InputManager.hpp"
|
||||
#include "ImagePreprocess.hpp"
|
||||
|
||||
#define DEFAULT_CANVAS_BACKGROUND_COLOR Vector3f{0.07f, 0.07f, 0.07f}
|
||||
|
||||
|
|
@ -112,6 +113,14 @@ class GlobalConfig {
|
|||
std::string displayName;
|
||||
bool flipZoomToolDirection = false;
|
||||
|
||||
// Fork: image insert options
|
||||
bool imageCompressOnInsert = true;
|
||||
ImagePreprocess::CompressFormat imageCompressFormat = ImagePreprocess::CompressFormat::WEBP;
|
||||
int imageWebpQuality = 80;
|
||||
bool imageFreistellenOnInsert = false;
|
||||
ImagePreprocess::FreistellenMode imageFreistellenMode = ImagePreprocess::FreistellenMode::WHITE_KEY;
|
||||
int imageWhiteKeyThreshold = 245;
|
||||
|
||||
bool disableGraphicsDriverWorkarounds = false;
|
||||
int vsyncValue = 0;
|
||||
|
||||
|
|
@ -135,3 +144,16 @@ NLOHMANN_JSON_SERIALIZE_ENUM(GlobalConfig::AntiAliasing, {
|
|||
{GlobalConfig::AntiAliasing::SKIA, "Skia"},
|
||||
{GlobalConfig::AntiAliasing::DYNAMIC_MSAA, "Dynamic MSAA"},
|
||||
})
|
||||
|
||||
NLOHMANN_JSON_SERIALIZE_ENUM(ImagePreprocess::CompressFormat, {
|
||||
{ImagePreprocess::CompressFormat::KEEP, "Keep"},
|
||||
{ImagePreprocess::CompressFormat::WEBP, "WebP"},
|
||||
{ImagePreprocess::CompressFormat::PNG, "PNG"},
|
||||
})
|
||||
|
||||
NLOHMANN_JSON_SERIALIZE_ENUM(ImagePreprocess::FreistellenMode, {
|
||||
{ImagePreprocess::FreistellenMode::OFF, "Off"},
|
||||
{ImagePreprocess::FreistellenMode::WHITE_KEY, "WhiteKey"},
|
||||
{ImagePreprocess::FreistellenMode::REMBG_SCHNELL, "RembgSchnell"},
|
||||
{ImagePreprocess::FreistellenMode::REMBG_ALLGEMEIN, "RembgAllgemein"},
|
||||
})
|
||||
|
|
|
|||
315
src/ImagePreprocess.cpp
Normal file
315
src/ImagePreprocess.cpp
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
/*
|
||||
* InfiniPaint (fork)
|
||||
* Image insert preprocessing: freistellen + compression options.
|
||||
*/
|
||||
|
||||
#include "ImagePreprocess.hpp"
|
||||
#include "GlobalConfig.hpp"
|
||||
#include <Helpers/Logger.hpp>
|
||||
|
||||
#include <include/codec/SkBmpDecoder.h>
|
||||
#include <include/codec/SkCodec.h>
|
||||
#include <include/codec/SkGifDecoder.h>
|
||||
#include <include/codec/SkIcoDecoder.h>
|
||||
#include <include/codec/SkJpegDecoder.h>
|
||||
#include <include/codec/SkPngDecoder.h>
|
||||
#include <include/codec/SkWbmpDecoder.h>
|
||||
#include <include/codec/SkWebpDecoder.h>
|
||||
#include <include/core/SkBitmap.h>
|
||||
#include <include/core/SkData.h>
|
||||
#include <include/core/SkPixmap.h>
|
||||
#include <include/core/SkStream.h>
|
||||
#include <include/encode/SkPngEncoder.h>
|
||||
#include <include/encode/SkWebpEncoder.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
#ifndef __EMSCRIPTEN__
|
||||
#include <cstdlib>
|
||||
#include <array>
|
||||
#endif
|
||||
|
||||
namespace ImagePreprocess {
|
||||
namespace {
|
||||
|
||||
std::array<const SkCodecs::Decoder, 7> imageDecoders = {
|
||||
SkBmpDecoder::Decoder(),
|
||||
SkGifDecoder::Decoder(),
|
||||
SkIcoDecoder::Decoder(),
|
||||
SkJpegDecoder::Decoder(),
|
||||
SkPngDecoder::Decoder(),
|
||||
SkWbmpDecoder::Decoder(),
|
||||
SkWebpDecoder::Decoder()
|
||||
};
|
||||
|
||||
std::string to_lower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string replace_extension(std::string name, const char* newExt) {
|
||||
auto slash = name.find_last_of("/\\");
|
||||
auto baseStart = slash == std::string::npos ? 0 : slash + 1;
|
||||
auto dot = name.find_last_of('.');
|
||||
if(dot != std::string::npos && dot > baseStart)
|
||||
name = name.substr(0, dot);
|
||||
name += newExt;
|
||||
return name;
|
||||
}
|
||||
|
||||
bool decode_first_frame(const std::string& data, SkBitmap& outBitmap) {
|
||||
sk_sp<SkData> skData = SkData::MakeWithoutCopy(data.data(), data.size());
|
||||
std::unique_ptr<SkCodec> codec = SkCodec::MakeFromData(skData, imageDecoders);
|
||||
if(!codec)
|
||||
return false;
|
||||
SkImageInfo info = codec->getInfo().makeColorType(kRGBA_8888_SkColorType).makeAlphaType(kUnpremul_SkAlphaType);
|
||||
if(!outBitmap.tryAllocPixels(info))
|
||||
return false;
|
||||
SkCodec::Options opts;
|
||||
opts.fFrameIndex = 0;
|
||||
if(codec->getPixels(info, outBitmap.getPixels(), outBitmap.rowBytes(), &opts) != SkCodec::Result::kSuccess)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void apply_white_key(SkBitmap& bitmap, int threshold) {
|
||||
threshold = std::clamp(threshold, 0, 255);
|
||||
SkPixmap pm;
|
||||
if(!bitmap.peekPixels(&pm))
|
||||
return;
|
||||
const int w = pm.width();
|
||||
const int h = pm.height();
|
||||
for(int y = 0; y < h; y++) {
|
||||
auto* row = reinterpret_cast<uint8_t*>(pm.writable_addr(0, y));
|
||||
for(int x = 0; x < w; x++) {
|
||||
uint8_t* p = row + x * 4;
|
||||
if(p[0] >= threshold && p[1] >= threshold && p[2] >= threshold) {
|
||||
p[3] = 0;
|
||||
}
|
||||
else if(p[0] > threshold - 25 && p[1] > threshold - 25 && p[2] > threshold - 25) {
|
||||
// Soft edge: fade alpha near threshold
|
||||
int minC = std::min({p[0], p[1], p[2]});
|
||||
int fade = threshold - minC;
|
||||
int alpha = (fade * 255) / 25;
|
||||
p[3] = static_cast<uint8_t>(std::clamp(std::min<int>(p[3], alpha), 0, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::string> encode_bitmap(const SkBitmap& bitmap, CompressFormat format, int webpQuality) {
|
||||
SkPixmap pm;
|
||||
if(!bitmap.peekPixels(&pm))
|
||||
return std::nullopt;
|
||||
SkDynamicMemoryWStream out;
|
||||
bool ok = false;
|
||||
if(format == CompressFormat::WEBP) {
|
||||
SkWebpEncoder::Options opts;
|
||||
opts.fCompression = SkWebpEncoder::Compression::kLossy;
|
||||
opts.fQuality = static_cast<float>(std::clamp(webpQuality, 0, 100));
|
||||
ok = SkWebpEncoder::Encode(&out, pm, opts);
|
||||
}
|
||||
else {
|
||||
ok = SkPngEncoder::Encode(&out, pm, {});
|
||||
}
|
||||
if(!ok)
|
||||
return std::nullopt;
|
||||
out.flush();
|
||||
auto skData = out.detachAsData();
|
||||
if(!skData)
|
||||
return std::nullopt;
|
||||
return std::string(reinterpret_cast<const char*>(skData->bytes()), skData->size());
|
||||
}
|
||||
|
||||
#ifndef __EMSCRIPTEN__
|
||||
std::optional<std::string> find_rembg_bin() {
|
||||
const char* envBin = std::getenv("TOWER_REMBG_BIN");
|
||||
if(envBin && envBin[0] && std::filesystem::is_regular_file(envBin))
|
||||
return std::string(envBin);
|
||||
const char* home = std::getenv("HOME");
|
||||
if(home) {
|
||||
std::filesystem::path venv = std::filesystem::path(home) / ".local/share/tower-rembg/bin/rembg";
|
||||
if(std::filesystem::is_regular_file(venv))
|
||||
return venv.string();
|
||||
std::filesystem::path local = std::filesystem::path(home) / ".local/bin/rembg";
|
||||
if(std::filesystem::is_regular_file(local))
|
||||
return local.string();
|
||||
}
|
||||
// PATH lookup via `command -v`
|
||||
FILE* pipe = popen("command -v rembg 2>/dev/null", "r");
|
||||
if(!pipe)
|
||||
return std::nullopt;
|
||||
char buf[512];
|
||||
std::string path;
|
||||
while(fgets(buf, sizeof(buf), pipe))
|
||||
path += buf;
|
||||
pclose(pipe);
|
||||
while(!path.empty() && (path.back() == '\n' || path.back() == '\r'))
|
||||
path.pop_back();
|
||||
if(!path.empty() && std::filesystem::is_regular_file(path))
|
||||
return path;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<std::string> run_rembg(const std::string& inputData, const std::string& model) {
|
||||
auto rembg = find_rembg_bin();
|
||||
if(!rembg) {
|
||||
Logger::get().log(Logger::LogType::INFO, "[ImagePreprocess] rembg not found — falling back to white-key");
|
||||
return std::nullopt;
|
||||
}
|
||||
std::error_code ec;
|
||||
auto tmpDir = std::filesystem::temp_directory_path(ec);
|
||||
if(ec)
|
||||
return std::nullopt;
|
||||
auto inPath = tmpDir / ("infinipaint-rembg-in-" + std::to_string(std::random_device{}()) + ".png");
|
||||
auto outPath = tmpDir / ("infinipaint-rembg-out-" + std::to_string(std::random_device{}()) + ".png");
|
||||
|
||||
{
|
||||
std::ofstream out(inPath, std::ios::binary);
|
||||
if(!out)
|
||||
return std::nullopt;
|
||||
out.write(inputData.data(), static_cast<std::streamsize>(inputData.size()));
|
||||
}
|
||||
|
||||
std::string cmd = "\"" + *rembg + "\" i -m " + model + " \"" + inPath.string() + "\" \"" + outPath.string() + "\" 2>/dev/null";
|
||||
int rc = std::system(cmd.c_str());
|
||||
std::optional<std::string> result;
|
||||
if(rc == 0 && std::filesystem::is_regular_file(outPath)) {
|
||||
std::ifstream in(outPath, std::ios::binary);
|
||||
if(in) {
|
||||
result = std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
||||
if(result->empty())
|
||||
result.reset();
|
||||
}
|
||||
}
|
||||
std::filesystem::remove(inPath, ec);
|
||||
std::filesystem::remove(outPath, ec);
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
bool looks_like_raster_image(std::string_view name, std::string_view data) {
|
||||
std::string lower = to_lower(std::string(name));
|
||||
auto hasExt = [&](const char* ext) {
|
||||
return lower.size() >= std::strlen(ext) && lower.compare(lower.size() - std::strlen(ext), std::strlen(ext), ext) == 0;
|
||||
};
|
||||
if(hasExt(".png") || hasExt(".jpg") || hasExt(".jpeg") || hasExt(".webp") || hasExt(".bmp") || hasExt(".gif") || hasExt(".ico"))
|
||||
return true;
|
||||
if(data.size() >= 8) {
|
||||
// PNG / JPEG / RIFF WEBP / GIF / BMP magic
|
||||
const auto* b = reinterpret_cast<const unsigned char*>(data.data());
|
||||
if(b[0] == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G')
|
||||
return true;
|
||||
if(b[0] == 0xFF && b[1] == 0xD8)
|
||||
return true;
|
||||
if(b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F')
|
||||
return true;
|
||||
if(b[0] == 'G' && b[1] == 'I' && b[2] == 'F')
|
||||
return true;
|
||||
if(b[0] == 'B' && b[1] == 'M')
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Options options_from_config(const GlobalConfig& conf) {
|
||||
Options o;
|
||||
o.compressOnInsert = conf.imageCompressOnInsert;
|
||||
o.compressFormat = conf.imageCompressFormat;
|
||||
o.webpQuality = conf.imageWebpQuality;
|
||||
o.freistellenOnInsert = conf.imageFreistellenOnInsert;
|
||||
o.freistellenMode = conf.imageFreistellenMode;
|
||||
o.whiteKeyThreshold = conf.imageWhiteKeyThreshold;
|
||||
return o;
|
||||
}
|
||||
|
||||
bool preprocess_resource(std::string& name, std::shared_ptr<std::string>& data, const Options& options) {
|
||||
if(!data || data->empty())
|
||||
return false;
|
||||
if(!looks_like_raster_image(name, *data))
|
||||
return false;
|
||||
|
||||
bool wantFreistellen = options.freistellenOnInsert && options.freistellenMode != FreistellenMode::OFF;
|
||||
bool wantCompress = options.compressOnInsert && options.compressFormat != CompressFormat::KEEP;
|
||||
if(!wantFreistellen && !wantCompress)
|
||||
return false;
|
||||
|
||||
std::string working = *data;
|
||||
bool changed = false;
|
||||
|
||||
if(wantFreistellen) {
|
||||
#ifndef __EMSCRIPTEN__
|
||||
if(options.freistellenMode == FreistellenMode::REMBG_SCHNELL || options.freistellenMode == FreistellenMode::REMBG_ALLGEMEIN) {
|
||||
const char* model = options.freistellenMode == FreistellenMode::REMBG_SCHNELL ? "silueta" : "bria-rmbg";
|
||||
// rembg wants an image file; feed current bytes (encode as PNG first if needed)
|
||||
SkBitmap bmp;
|
||||
std::string rembgInput = working;
|
||||
if(decode_first_frame(working, bmp)) {
|
||||
auto png = encode_bitmap(bmp, CompressFormat::PNG, 100);
|
||||
if(png)
|
||||
rembgInput = *png;
|
||||
}
|
||||
if(auto rembgOut = run_rembg(rembgInput, model)) {
|
||||
working = *rembgOut;
|
||||
changed = true;
|
||||
name = replace_extension(name, ".png");
|
||||
}
|
||||
else {
|
||||
// Fallback white-key
|
||||
if(decode_first_frame(working, bmp)) {
|
||||
apply_white_key(bmp, options.whiteKeyThreshold);
|
||||
if(auto enc = encode_bitmap(bmp, CompressFormat::PNG, 100)) {
|
||||
working = *enc;
|
||||
changed = true;
|
||||
name = replace_extension(name, ".png");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
#endif
|
||||
{
|
||||
SkBitmap bmp;
|
||||
if(decode_first_frame(working, bmp)) {
|
||||
apply_white_key(bmp, options.whiteKeyThreshold);
|
||||
if(auto enc = encode_bitmap(bmp, CompressFormat::PNG, 100)) {
|
||||
working = *enc;
|
||||
changed = true;
|
||||
name = replace_extension(name, ".png");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(wantCompress) {
|
||||
SkBitmap bmp;
|
||||
if(decode_first_frame(working, bmp)) {
|
||||
CompressFormat fmt = options.compressFormat;
|
||||
if(auto enc = encode_bitmap(bmp, fmt, options.webpQuality)) {
|
||||
// Keep result if smaller, or if freistellen required alpha-friendly format
|
||||
bool preferNew = enc->size() < working.size() || wantFreistellen || fmt == CompressFormat::WEBP;
|
||||
if(preferNew) {
|
||||
working = *enc;
|
||||
changed = true;
|
||||
name = replace_extension(name, fmt == CompressFormat::WEBP ? ".webp" : ".png");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(changed)
|
||||
data = std::make_shared<std::string>(std::move(working));
|
||||
return changed;
|
||||
}
|
||||
|
||||
}
|
||||
45
src/ImagePreprocess.hpp
Normal file
45
src/ImagePreprocess.hpp
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* InfiniPaint (fork)
|
||||
* Image insert preprocessing: freistellen + compression options.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
class GlobalConfig;
|
||||
|
||||
namespace ImagePreprocess {
|
||||
|
||||
enum class CompressFormat : uint8_t {
|
||||
KEEP = 0,
|
||||
WEBP,
|
||||
PNG
|
||||
};
|
||||
|
||||
enum class FreistellenMode : uint8_t {
|
||||
OFF = 0,
|
||||
WHITE_KEY, // Near-white → transparent (always available)
|
||||
REMBG_SCHNELL, // rembg -m silueta (Tower freistellen/schnell)
|
||||
REMBG_ALLGEMEIN // rembg -m bria-rmbg (Tower freistellen/allgemein)
|
||||
};
|
||||
|
||||
struct Options {
|
||||
bool compressOnInsert = true;
|
||||
CompressFormat compressFormat = CompressFormat::WEBP;
|
||||
int webpQuality = 80; // 0–100, lossy
|
||||
bool freistellenOnInsert = false;
|
||||
FreistellenMode freistellenMode = FreistellenMode::WHITE_KEY;
|
||||
int whiteKeyThreshold = 245; // 0–255, RGB channels all >= threshold → transparent
|
||||
};
|
||||
|
||||
// Mutates name + data in place when preprocessing applies. Returns true if changed.
|
||||
bool preprocess_resource(std::string& name, std::shared_ptr<std::string>& data, const Options& options);
|
||||
|
||||
Options options_from_config(const GlobalConfig& conf);
|
||||
|
||||
bool looks_like_raster_image(std::string_view name, std::string_view data);
|
||||
|
||||
}
|
||||
|
|
@ -346,6 +346,14 @@ void PhoneDrawingProgramScreen::top_toolbar_settings_popup() {
|
|||
.floating = {.offset = {.x = 0, .y = static_cast<float>(io.theme->padding1)}, .zIndex = gui.get_z_index(), .attachPoints = {.element = CLAY_ATTACH_POINT_RIGHT_TOP, .parent = CLAY_ATTACH_POINT_RIGHT_BOTTOM}, .attachTo = CLAY_ATTACH_TO_PARENT}
|
||||
}) {
|
||||
text_label_centered(gui, "Player List");
|
||||
if(main.world->netServer) {
|
||||
text_button(gui, "force all jump to host phone", "Bring Everyone Here", {
|
||||
.wide = true,
|
||||
.onClick = [&] {
|
||||
main.world->drawProg.force_clients_to_own_view();
|
||||
}
|
||||
});
|
||||
}
|
||||
left_to_right_line_layout(gui, [&]() {
|
||||
CLAY_AUTO_ID({
|
||||
.layout = {
|
||||
|
|
|
|||
|
|
@ -1211,6 +1211,14 @@ void Toolbar::player_list() {
|
|||
gui.new_id("client list", [&] {
|
||||
text_label_centered(gui, "Player List");
|
||||
if(!main.world->clientStillConnecting) {
|
||||
if(main.world->netServer) {
|
||||
text_button(gui, "force all jump to host", "Bring Everyone Here", {
|
||||
.wide = true,
|
||||
.onClick = [&] {
|
||||
main.world->drawProg.force_clients_to_own_view();
|
||||
}
|
||||
});
|
||||
}
|
||||
left_to_right_line_layout(gui, [&]() {
|
||||
CLAY_AUTO_ID({
|
||||
.layout = {
|
||||
|
|
@ -1463,6 +1471,7 @@ void Toolbar::general_settings_inner_gui() {
|
|||
});
|
||||
};
|
||||
category_button("Generalbutton", "General", GSETTINGS_GENERAL);
|
||||
category_button("Imagesbutton", "Images", GSETTINGS_IMAGES);
|
||||
category_button("Graphicsbutton", "Graphics", GSETTINGS_GRAPHICS);
|
||||
category_button("Tabletbutton", "Tablet", GSETTINGS_TABLET);
|
||||
category_button("Themebutton", "Theme", GSETTINGS_THEME);
|
||||
|
|
@ -1524,6 +1533,35 @@ void Toolbar::general_settings_inner_gui() {
|
|||
});
|
||||
break;
|
||||
}
|
||||
case GSETTINGS_IMAGES: {
|
||||
general_scroll_area("image settings", [&] {
|
||||
text_label(gui, "On insert / paste:");
|
||||
checkbox_boolean_field(gui, "compress images on insert", "Compress images automatically", &main.conf.imageCompressOnInsert);
|
||||
text_label(gui, "Compression format:");
|
||||
radio_button_selector(gui, "image compress format", &main.conf.imageCompressFormat, {
|
||||
{"Keep original", ImagePreprocess::CompressFormat::KEEP},
|
||||
{"WebP", ImagePreprocess::CompressFormat::WEBP},
|
||||
{"PNG", ImagePreprocess::CompressFormat::PNG}
|
||||
});
|
||||
slider_scalar_field(gui, "webp quality", "WebP quality", &main.conf.imageWebpQuality, 1, 100);
|
||||
checkbox_boolean_field(gui, "freistellen on insert", "Freistellen / remove background on insert", &main.conf.imageFreistellenOnInsert);
|
||||
text_label(gui, "Freistellen mode:");
|
||||
radio_button_selector(gui, "freistellen mode", &main.conf.imageFreistellenMode, {
|
||||
{"Off", ImagePreprocess::FreistellenMode::OFF},
|
||||
{"White background", ImagePreprocess::FreistellenMode::WHITE_KEY},
|
||||
#ifndef __EMSCRIPTEN__
|
||||
{"Schnell (rembg silueta)", ImagePreprocess::FreistellenMode::REMBG_SCHNELL},
|
||||
{"Allgemein (rembg bria)", ImagePreprocess::FreistellenMode::REMBG_ALLGEMEIN},
|
||||
#endif
|
||||
});
|
||||
slider_scalar_field(gui, "white key threshold", "White-key threshold", &main.conf.imageWhiteKeyThreshold, 200, 255);
|
||||
text_label_light(gui, "Selection toolbar: Compress Images / Freistellen also work afterwards.");
|
||||
#ifndef __EMSCRIPTEN__
|
||||
text_label_light(gui, "rembg uses Tower venv (~/.local/share/tower-rembg) when present.");
|
||||
#endif
|
||||
});
|
||||
break;
|
||||
}
|
||||
case GSETTINGS_GRAPHICS: {
|
||||
general_scroll_area("graphics settings", [&] {
|
||||
input_scalar_field(gui, "Max GUI Scale", "Max GUI Scale", &main.conf.guiScale, 0.5f, 5.0f, {
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ class Toolbar {
|
|||
} optionsMenuType;
|
||||
enum GeneralSettingsOptions {
|
||||
GSETTINGS_GENERAL = 0,
|
||||
GSETTINGS_IMAGES,
|
||||
GSETTINGS_GRAPHICS,
|
||||
GSETTINGS_TABLET,
|
||||
GSETTINGS_THEME,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ VersionNumber header_to_version_number(const std::string& header) {
|
|||
m["INFPNT000004"] = VersionNumber(0, 3, 0);
|
||||
m["INFPNT000005"] = VersionNumber(0, 4, 0);
|
||||
m["INFPNT000006"] = VersionNumber(0, 6, 0);
|
||||
m["INFPNT000007"] = VersionNumber(0, 7, 0);
|
||||
}
|
||||
auto it = m.find(header);
|
||||
if(it == m.end())
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ namespace VersionConstants {
|
|||
VersionNumber header_to_version_number(const std::string& header);
|
||||
|
||||
constexpr int SAVEFILE_HEADER_LEN = 12; // DO NOT CHANGE THIS HEADER LENGTH
|
||||
const std::string CURRENT_SAVEFILE_HEADER = "INFPNT000006"; // Change whenever the save file is incompatible with the previous version
|
||||
const std::string CURRENT_VERSION_STRING = "0.6.1";
|
||||
constexpr VersionNumber CURRENT_VERSION_NUMBER(0, 6, 1);
|
||||
const std::string CURRENT_SAVEFILE_HEADER = "INFPNT000007"; // Change whenever the save file is incompatible with the previous version
|
||||
const std::string CURRENT_VERSION_STRING = "0.7.0-fork";
|
||||
constexpr VersionNumber CURRENT_VERSION_NUMBER(0, 7, 0);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue