-
Notifications
You must be signed in to change notification settings - Fork 76
Expand file tree
/
Copy pathlingdong.py
More file actions
6152 lines (5197 loc) · 263 KB
/
lingdong.py
File metadata and controls
6152 lines (5197 loc) · 263 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
灵动智能体 - 无限画布工作空间
类似ComfyUI的可拖动画布,支持多种内容节点
"""
import os
import json
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit,
QPushButton, QComboBox, QFrame, QGraphicsView, QGraphicsScene,
QGraphicsItem, QGraphicsRectItem, QGraphicsTextItem, QGraphicsPixmapItem,
QMessageBox, QFileDialog, QTextBrowser, QSplitter, QMenu, QGraphicsDropShadowEffect,
QDialog, QTableWidgetItem, QGraphicsProxyWidget, QCompleter, QAbstractItemView
)
from PySide6.QtCore import Qt, QRectF, QPointF, Signal, QTimer, QSettings, QEvent, QSize, QStringListModel
from PySide6.QtGui import (
QFont, QPainter, QColor, QPen, QBrush, QPixmap,
QWheelEvent, QMouseEvent, QPainterPath, QAction, QLinearGradient, QTextCursor, QIcon
)
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtSvgWidgets import QSvgWidget
# 导入词语优化模块
from lingdongWordPost import optimize_word
# 导入文字节点模块
from lingdongTXT import TextNode as TextNodeFactory
# 导入图片节点模块
from lingdongpng import ImageNode as ImageNodeFactory
from GONGZUOTAI_back import WorkbenchToggleButton
from database_save import LibraryPanel, LibraryToggleButton
# 导入视频节点模块
from lingdongvideo import VideoNode as VideoNodeFactory
# 导入表格节点模块
from lingdongstoryboard import StoryboardNode as StoryboardNodeFactory
# 导入人物表格节点模块(AI生成)- 已移除
# from lingdongpeople import create_people_node, generate_all_people_from_storyboard
# 导入人物节点模块(手动创建)- 已移除
# from lingdongRenwujiedian import create_people_node as PeopleNodeFactory
# 导入模型选择节点模块
from lingdongmodelxuanze import ModelSelectionNode as ModelSelectionNodeFactory
# 导入谷歌剧本节点模块
from lingdonggooglejuben import GoogleScriptNode as GoogleScriptNodeFactory
# 导入剧本人物节点模块
from LDjubenrenwu import ScriptCharacterNode as ScriptCharacterNodeFactory
# 导入自动文本节点管理器
from work_open_GPT import AutoTextNodeManager
# 导入聊天节点上下文管理器
from chat_abot import ChatNodeContextManager
from chat_image_upload import ChatImageUploadManager
# 导入草稿生成模块
from lingdongDraft import DraftGenerator
# 导入谷歌提示词列模块
try:
from lingdonggugetishici import add_prompt_column
except ImportError:
print("Warning: lingdonggugetishici module not found")
def add_prompt_column(node):
print("add_prompt_column not available")
# 导入导演节点模块
from guge_TV_GO import DirectorNode as DirectorNodeFactory
# 导入连线系统
from lingdongconnect import (
Socket, SocketType, DataType, Connection, ConnectionManager, ConnectableNode
)
# 导入绘画提示词生成模块
from lingdongPrompt import DrawingPromptGenerator
# 导入草稿生成模块
from lingdongDraft import DraftGenerator
# 导入视频拆分模块
from video_image_chaifen import VideoSplitDialog, is_video_connected_to_image
# 导入案例管理挂件
from lingdongANli import CaseManagerWidget
# 导入抽卡节点模块
from chouka import GachaNodeFactory
# ==================== ChatWorker AI聊天工作线程 ====================
from PySide6.QtCore import QThread, Signal
class ChatWorker(QThread):
"""AI聊天工作线程"""
response_received = Signal(str)
chunk_received = Signal(str)
error_occurred = Signal(str)
finished = Signal()
def __init__(self, provider, model, messages, api_key, api_url, hunyuan_api_url):
super().__init__()
# Register to global registry
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app:
if not hasattr(app, '_active_chat_workers'):
app._active_chat_workers = []
app._active_chat_workers.append(self)
self.finished.connect(self._cleanup_worker)
self.provider = provider
self.model = model
self.messages = messages
self.api_key = api_key
if provider == "Hunyuan":
self.api_url = hunyuan_api_url
else:
self.api_url = api_url
self._stop_requested = False
def _cleanup_worker(self):
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app and hasattr(app, '_active_chat_workers'):
if self in app._active_chat_workers:
app._active_chat_workers.remove(self)
self.deleteLater()
def stop(self):
self._stop_requested = True
def run(self):
try:
print(f"[ChatWorker] Start request: {self.model} to {self.api_url}")
import http.client
import ssl
import json as _json
from urllib.parse import urlparse
parsed = urlparse(self.api_url)
host = parsed.netloc or parsed.path.split('/')[0]
scheme = parsed.scheme
print(f"[ChatWorker] Host: {host}, Scheme: {scheme}")
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
payload = {
"model": self.model,
"messages": self.messages,
"temperature": 0.7,
"max_tokens": 8192,
"stream": True
}
# Decide connection type based on scheme
if scheme == 'http':
conn = http.client.HTTPConnection(host, timeout=300)
else:
context = ssl.create_default_context()
conn = http.client.HTTPSConnection(host, context=context, timeout=300)
print(f"[ChatWorker] Sending request...")
# Use parsed path if available and valid, else default
# Note: Many APIs use /v1/chat/completions relative to host.
# If user provided full path in api_url, we might need to adjust.
# But standard practice in this codebase seems to be api_url is base.
conn.request('POST', '/v1/chat/completions', _json.dumps(payload), headers)
res = conn.getresponse()
print(f"[ChatWorker] Response status: {res.status}")
if res.status == 200:
ct = res.getheader('Content-Type') or ''
print(f"[ChatWorker] Content-Type: {ct}")
full_content = ""
# Check for streaming response
# Note: Some proxies might not set Content-Type to text/event-stream but still stream.
# We assume streaming because we requested stream=True.
for line in res:
if self._stop_requested:
print("[ChatWorker] Stop requested")
break
line = line.decode('utf-8').strip()
if not line or line == "data: [DONE]":
continue
if line.startswith("data: "):
json_str = line[6:]
try:
chunk_data = _json.loads(json_str)
if 'choices' in chunk_data and len(chunk_data['choices']) > 0:
delta = chunk_data['choices'][0].get('delta', {})
content = delta.get('content', '')
if content:
full_content += content
self.chunk_received.emit(content)
except _json.JSONDecodeError:
print(f"[ChatWorker] JSON Parse Error: {json_str}")
continue
self.response_received.emit(full_content)
else:
error_data = res.read().decode('utf-8')
print(f"[ChatWorker] Error data: {error_data}")
self.error_occurred.emit(f"API错误 ({res.status}): {error_data[:200]}")
conn.close()
except Exception as e:
print(f"[ChatWorker] Exception: {str(e)}")
import traceback
traceback.print_exc()
self.error_occurred.emit(f"请求失败: {str(e)}")
finally:
self.finished.emit()
# ==================== SVG图标定义 ====================
SVG_TEXT_ICON = '''<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 7h16M12 7v13m-5 0h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>'''
SVG_IMAGE_ICON = '''<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/>
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor"/>
<path d="M3 16l5-5 3 3 5-5 5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>'''
SVG_VIDEO_ICON = '''<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="5" width="14" height="14" rx="2" stroke="currentColor" stroke-width="2"/>
<path d="M16 10l6-3v10l-6-3z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
</svg>'''
SVG_DOC_ICON = '''<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="2"/>
<path d="M14 2v6h6M16 13H8m8 4H8m2-8H8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>'''
SVG_TABLE_ICON = '''<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/>
<path d="M3 9h18M3 15h18M9 3v18" stroke="currentColor" stroke-width="2"/>
</svg>'''
# ==================== 调整大小手柄 ====================
class ResizeHandle(QGraphicsItem):
def __init__(self, parent):
super().__init__(parent)
self.setParentItem(parent)
self.setCursor(Qt.CursorShape.SizeFDiagCursor)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
self.setAcceptHoverEvents(True)
self.parent_node = parent
self.setZValue(999) # 确保在最上层
self.handle_size = 20
self.update_position()
def boundingRect(self):
return QRectF(0, 0, self.handle_size, self.handle_size)
def paint(self, painter, option, widget):
if self.parent_node.is_collapsed:
return
painter.setPen(QPen(QColor("#cccccc"), 2))
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# 绘制两条斜线
s = self.handle_size
painter.drawLine(s - 15, s - 5, s - 5, s - 15)
painter.drawLine(s - 9, s - 5, s - 5, s - 9)
def mousePressEvent(self, event):
self.start_pos = event.screenPos()
self.start_rect = self.parent_node.rect()
event.accept()
def mouseMoveEvent(self, event):
diff = event.screenPos() - self.start_pos
new_width = max(200, self.start_rect.width() + diff.x())
new_height = max(100, self.start_rect.height() + diff.y())
self.parent_node.setRect(0, 0, new_width, new_height)
if hasattr(self.parent_node, 'expanded_height'):
self.parent_node.expanded_height = new_height
self.update_position()
def update_position(self):
rect = self.parent_node.rect()
self.setPos(rect.width() - self.handle_size, rect.height() - self.handle_size)
# ==================== 画布节点基类 ====================
class CanvasNode(QGraphicsRectItem):
"""画布节点基类 - 可拖动、可选中、可删除、可连接"""
def __init__(self, x, y, width, height, title, icon_svg):
super().__init__(0, 0, width, height)
self.node_title = title
self.icon_svg = icon_svg
# 设置位置
self.setPos(x, y)
# 设置标志
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsFocusable) # 支持键盘焦点
# 创建调整大小手柄
self.resize_handle = ResizeHandle(self)
# 阴影效果 (Material Elevation)
shadow = QGraphicsDropShadowEffect()
shadow.setBlurRadius(24)
shadow.setOffset(0, 6)
shadow.setColor(QColor(0, 0, 0, 30))
self.setGraphicsEffect(shadow)
# 样式设置
self.setPen(QPen(QColor("#DADCE0"), 1.5))
self.setBrush(QBrush(QColor("#ffffff")))
# 自定义头部颜色
self.header_color = None
# 创建标题文本
self.title_text = QGraphicsTextItem(self)
self.title_text.setPlainText(title)
self.title_text.setDefaultTextColor(QColor("#3C4043"))
self.title_text.setFont(QFont("Microsoft YaHei UI", 10, QFont.Weight.Bold))
self.title_text.setPos(50, 12)
# 选中状态
self.is_selected = False
# 可编辑内容(子类覆盖)
self.editable_text = None
# ========== 连接功能 ==========
self.input_sockets = [] # 输入接口列表
self.output_sockets = [] # 输出接口列表
# 自动添加默认接口(左侧输入,右侧输出)
self._auto_create_sockets()
# ========== 折叠功能 ==========
self.is_collapsed = False
self.expanded_height = height
self.collapsed_height = 48
# 折叠/展开按钮
self.toggle_btn = QGraphicsTextItem(self)
self.toggle_btn.setPlainText("▼")
self.toggle_btn.setDefaultTextColor(QColor("#1a73e8"))
self.toggle_btn.setFont(QFont("Arial", 12, QFont.Weight.Bold))
self.toggle_btn.setPos(width - 30, 10)
# 允许鼠标事件以便点击
self.toggle_btn.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
# 拦截鼠标点击事件
self.toggle_btn.mousePressEvent = lambda e: self.toggle_collapse()
def set_header_color(self, color):
"""设置自定义头部颜色"""
if isinstance(color, str):
self.header_color = QColor(color)
else:
self.header_color = color
self.update()
def mousePressEvent(self, event):
"""处理鼠标点击事件"""
# 修复输入框/表格焦点问题:如果点击了代理控件,优先处理,不让父节点拦截
for child in self.childItems():
if isinstance(child, QGraphicsProxyWidget):
# 检查点击位置是否在控件内
if child.contains(child.mapFromParent(event.pos())):
child.setFocus()
# 确保场景也将焦点给它
if self.scene():
self.scene().setFocusItem(child)
# 既然点击了内部控件,就不应该触发节点的移动或选中逻辑
# 直接返回,不再调用 super().mousePressEvent(event)
# 注意:这假设 ProxyWidget 会通过其他机制(如事件过滤或直接分发)接收事件
# 如果 ProxyWidget 之前因为某种原因没收到事件,这里仅仅 return 可能还不够
# 但这至少解决了"点击输入框却变成拖动节点"的问题
return
super().mousePressEvent(event)
def setRect(self, *args):
"""重写setRect以在大小改变时更新接口位置"""
super().setRect(*args)
# 更新调整大小手柄位置
if hasattr(self, 'resize_handle'):
self.resize_handle.update_position()
# 只有在初始化完成后才更新(避免__init__中调用出错)
if hasattr(self, 'input_sockets') and hasattr(self, 'output_sockets'):
self.update_socket_positions()
self.update_connections()
def _auto_create_sockets(self):
"""自动创建默认接口"""
if getattr(self, "disable_auto_sockets", False):
return
# 根据节点类型自动判断数据类型
data_type = DataType.ANY
if "文字" in self.node_title or "文本" in self.node_title:
data_type = DataType.TEXT
elif "图片" in self.node_title:
data_type = DataType.IMAGE
elif "视频" in self.node_title:
data_type = DataType.VIDEO
elif "表格" in self.node_title or "分镜" in self.node_title:
data_type = DataType.TABLE
# 添加一个输入和一个输出接口
self.add_input_socket(data_type, "输入")
self.add_output_socket(data_type, "输出")
def add_input_socket(self, data_type, label=""):
"""添加输入接口"""
index = len(self.input_sockets)
socket = Socket(self, SocketType.INPUT, data_type, index, label)
self.input_sockets.append(socket)
return socket
def add_output_socket(self, data_type, label=""):
"""添加输出接口"""
index = len(self.output_sockets)
socket = Socket(self, SocketType.OUTPUT, data_type, index, label)
self.output_sockets.append(socket)
return socket
def get_socket_at_pos(self, pos):
"""获取指定位置的接口"""
for socket in self.input_sockets + self.output_sockets:
if socket.contains(socket.mapFromScene(pos)):
return socket
return None
def update_socket_positions(self):
"""更新所有接口位置"""
for socket in self.input_sockets + self.output_sockets:
socket.update_position()
def update_connections(self):
"""更新所有连接线"""
for socket in self.input_sockets + self.output_sockets:
for connection in socket.connections:
connection.update_path()
def get_input_data(self, socket_index):
"""获取输入接口的数据"""
if socket_index >= len(self.input_sockets):
return None
socket = self.input_sockets[socket_index]
if not socket.connections:
return None
# 获取连接的源节点
connection = socket.connections[0]
source_node = connection.source_socket.parent_node
# 如果源节点有输出方法,调用它
if hasattr(source_node, 'get_output_data'):
source_index = connection.source_socket.index
return source_node.get_output_data(source_index)
return None
def get_output_data(self, socket_index):
"""获取输出数据(子类可以覆盖)"""
# 默认返回节点标题
return self.node_title
def itemChange(self, change, value):
"""节点变化时更新连接"""
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.update_connections()
# 防止 QGraphicsItem::ungrabMouse 错误
if change == QGraphicsItem.GraphicsItemChange.ItemSceneChange:
if value is None and self.scene():
grabber = self.scene().mouseGrabberItem()
if grabber and (grabber == self or self.isAncestorOf(grabber)):
grabber.ungrabMouse()
return super().itemChange(change, value)
def toggle_collapse(self):
"""切换折叠/展开状态"""
self.is_collapsed = not self.is_collapsed
self.toggle_btn.setPlainText("▶" if self.is_collapsed else "▼")
if self.is_collapsed:
# 记录当前高度以便恢复
if self.rect().height() > self.collapsed_height:
self.expanded_height = self.rect().height()
# 设置为折叠高度
self.setRect(0, 0, self.rect().width(), self.collapsed_height)
# 隐藏内容 items
self._set_content_visible(False)
else:
# 恢复展开高度
self.setRect(0, 0, self.rect().width(), self.expanded_height)
# 显示内容 items
self._set_content_visible(True)
# 更新接口位置和连接线
self.update_socket_positions()
self.update_connections()
def _set_content_visible(self, visible):
"""设置内容可见性"""
# 保持可见的 items
keep_visible = [
self.title_text,
self.toggle_btn,
]
# 添加 sockets 到保持可见列表
keep_visible.extend(self.input_sockets)
keep_visible.extend(self.output_sockets)
# 遍历所有子 item
for item in self.childItems():
# 如果不在保持可见列表中,则设置可见性
if item not in keep_visible:
if not visible:
# 记录当前可见性
is_currently_visible = item.isVisible()
item.setData(0, is_currently_visible) # Key 0 for visibility
item.setVisible(False)
else:
# 恢复之前的可见性
was_visible = item.data(0)
if was_visible is None: # 如果没有记录,默认为 True
was_visible = True
item.setVisible(was_visible)
def paint(self, painter, option, widget):
"""自定义绘制"""
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.rect()
path = QPainterPath()
path.addRoundedRect(rect, 16, 16)
# 获取背景色
bg_color = self.brush().color()
is_dark = bg_color.lightness() < 128
# 绘制背景
painter.fillPath(path, bg_color)
# 绘制头部 (顶部圆角)
header_height = 48
header_path = QPainterPath()
header_path.moveTo(rect.left(), rect.top() + 16)
header_path.arcTo(rect.left(), rect.top(), 32, 32, 180, -90) # Top-left corner
header_path.lineTo(rect.right() - 16, rect.top())
header_path.arcTo(rect.right() - 32, rect.top(), 32, 32, 90, -90) # Top-right corner
header_path.lineTo(rect.right(), rect.top() + header_height)
header_path.lineTo(rect.left(), rect.top() + header_height)
header_path.closeSubpath()
# 头部渐变
header_gradient = QLinearGradient(rect.topLeft(), QPointF(rect.left(), rect.top() + header_height))
if hasattr(self, 'header_color') and self.header_color:
# 自定义颜色
header_gradient.setColorAt(0, self.header_color.lighter(110))
header_gradient.setColorAt(1, self.header_color)
# 判断亮度决定文字颜色
if self.header_color.lightness() < 128:
self.title_text.setDefaultTextColor(QColor("#FFFFFF"))
icon_color = QColor("#FFFFFF")
else:
self.title_text.setDefaultTextColor(QColor("#3C4043"))
icon_color = self.header_color.darker(150)
divider_color = self.header_color.darker(110)
border_color = self.header_color
icon_bg_color = QColor(255, 255, 255, 128) # 半透明白色
elif is_dark:
# 深色模式
header_gradient.setColorAt(0, QColor("#2a2a2a"))
header_gradient.setColorAt(1, QColor("#222222"))
self.title_text.setDefaultTextColor(QColor("#FFFFFF"))
divider_color = QColor("#333333")
border_color = QColor("#333333")
icon_bg_color = QColor("#333333")
icon_color = QColor("#00ff88")
else:
# 浅色模式
header_gradient.setColorAt(0, QColor("#F8F9FA"))
header_gradient.setColorAt(1, QColor("#EFF1F3"))
self.title_text.setDefaultTextColor(QColor("#3C4043"))
divider_color = QColor("#E8EAED")
border_color = QColor("#DADCE0")
icon_bg_color = QColor("#E8F0FE")
icon_color = QColor("#1967D2")
painter.fillPath(header_path, header_gradient)
# 绘制边框
if self.isSelected():
painter.setPen(QPen(QColor("#1A73E8"), 2.5)) # Google Blue
painter.drawPath(path)
else:
painter.setPen(QPen(border_color, 1.5))
painter.drawPath(path)
# 头部底部分割线
painter.setPen(QPen(divider_color, 1))
painter.drawLine(rect.left(), rect.top() + header_height, rect.right(), rect.top() + header_height)
# 绘制图标背景 (圆形)
icon_rect = QRectF(14, 10, 28, 28)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(icon_bg_color))
painter.drawEllipse(icon_rect)
# 绘制图标(简化版)
painter.setPen(QPen(icon_color, 2))
font = painter.font()
font.setPixelSize(14)
font.setBold(True)
painter.setFont(font)
if "T" in self.node_title or "文字" in self.node_title:
painter.drawText(icon_rect, Qt.AlignmentFlag.AlignCenter, "T")
elif "图片" in self.node_title:
r = icon_rect.adjusted(6, 7, -6, -7)
painter.drawRoundedRect(r, 2, 2)
painter.drawEllipse(r.center(), 2, 2)
elif "视频" in self.node_title:
painter.drawText(icon_rect, Qt.AlignmentFlag.AlignCenter, "▶")
elif "人物" in self.node_title or "角色" in self.node_title:
painter.drawText(icon_rect, Qt.AlignmentFlag.AlignCenter, "👤")
elif "地点" in self.node_title or "环境" in self.node_title:
painter.drawText(icon_rect, Qt.AlignmentFlag.AlignCenter, "📍")
elif "模型" in self.node_title:
painter.drawText(icon_rect, Qt.AlignmentFlag.AlignCenter, "◆")
elif "表格" in self.node_title or "分镜" in self.node_title:
painter.drawText(icon_rect, Qt.AlignmentFlag.AlignCenter, "⊞")
elif "谷歌剧本" in self.node_title:
painter.drawText(icon_rect, Qt.AlignmentFlag.AlignCenter, "G")
def mouseDoubleClickEvent(self, event):
"""双击事件 - 子类可以覆盖以实现编辑功能"""
super().mouseDoubleClickEvent(event)
# ==================== 动态创建文字节点 ====================
# 从 lingdongTXT 模块导入并创建 TextNode 类
TextNode = TextNodeFactory.create_text_node(CanvasNode)
# ==================== 动态创建图片节点 ====================
# 从 lingdongpng 模块导入并创建 ImageNode 类
ImageNode = ImageNodeFactory.create_image_node(CanvasNode)
# ==================== 动态创建视频节点 ====================
# 从 lingdongvideo 模块导入并创建 VideoNode 类
VideoNode = VideoNodeFactory.create_video_node(CanvasNode)
# ==================== 动态创建表格节点 ====================
# 从 lingdongstoryboard 模块导入并创建 StoryboardNode 类
StoryboardNode = StoryboardNodeFactory.create_storyboard_node(CanvasNode)
# ==================== 动态创建人物节点 ====================
# 已移除
# PeopleNode = PeopleNodeFactory(CanvasNode)
# ==================== 动态创建模型选择节点 ====================
# 从 lingdongmodelxuanze 模块导入并创建 ModelSelectionNode 类
ModelSelectionNode = ModelSelectionNodeFactory.create_node(CanvasNode)
# ==================== 动态创建谷歌剧本节点 ====================
# 从 lingdonggooglejuben 模块导入并创建 GoogleScriptNode 类
GoogleScriptNode = GoogleScriptNodeFactory.create_node(CanvasNode)
# ==================== 动态创建剧本人物节点 ====================
# 从 LDjubenrenwu 模块导入并创建 ScriptCharacterNode 类
ScriptCharacterNode = ScriptCharacterNodeFactory.create_node(CanvasNode)
# ==================== 动态创建地点节点 ====================
# 从 didian 模块导入并创建 LocationNode 类
from didian import LocationNode as LocationNodeFactory
LocationNode = LocationNodeFactory.create_node(CanvasNode)
# ==================== 动态创建清理节点 ====================
# 从 Cleaning 模块导入并创建 CleaningNode 类
from Cleaning import CleaningNode as CleaningNodeFactory
from Geminianalyze import GeminiAnalyzeNodeFactory
CleaningNode = CleaningNodeFactory.create_node(CanvasNode)
GeminiAnalyzeNode = GeminiAnalyzeNodeFactory.create_node(CanvasNode)
# ==================== 动态创建抽卡节点 ====================
# 从 chouka 模块导入并创建 GachaNode 类
GachaNode = GachaNodeFactory.create_node(CanvasNode)
# ==================== 其他节点类型 ====================
# DocumentNode已被PeopleNode替代
class TableNode(CanvasNode):
"""表格节点 - 用于显示分镜脚本"""
def __init__(self, x, y, table_data=None):
# 动态计算表格大小 (加宽以容纳更多内容)
initial_width = 1400 # 从1200增加到1400
initial_height = 800 # 从600增加到800
super().__init__(x, y, initial_width, initial_height, "分镜表格", SVG_TABLE_ICON)
# 强制设置为浅色主题 (覆盖可能继承的默认值)
self.setBrush(QBrush(QColor("#ffffff")))
self.setPen(QPen(QColor("#DADCE0"), 1.5))
# 表格数据 [{'镜头号': '1', '景别': '全景', ...}, ...]
self.table_data = table_data or []
# 创建表格显示区域
self.table_html = QGraphicsTextItem(self)
self.table_html.setDefaultTextColor(QColor("#202124")) # 改为深色文字
self.table_html.setPos(20, 60) # 增加顶部和左侧边距
self.table_html.setTextWidth(initial_width - 40)
# 设置表格内容
self.update_table_display()
def update_table_display(self):
"""更新表格显示"""
if not self.table_data:
self.table_html.setHtml("""
<div style='color: #5f6368; padding: 40px; text-align: center; font-family: "Microsoft YaHei UI";'>
<p style='font-size: 14px;'>暂无分镜数据</p>
</div>
""")
return
# 生成HTML表格 - Android Material Design Light 风格
# 使用 div 容器模拟圆角表格
html = '''
<div style="background-color: #ffffff; border-radius: 12px; overflow: hidden; border: 1px solid #e0e0e0;">
<table style="width: 100%; border-collapse: collapse; border-spacing: 0;
font-family: 'Microsoft YaHei UI', 'Segoe UI', sans-serif;
font-size: 13px; line-height: 1.6; color: #202124;">
'''
# 表头
html += '<thead style="background-color: #f8f9fa;"><tr>'
headers = ['镜号', '时间码', '景别', '画面内容', '人物', '人物关系/构图', '地点/环境', '运镜', '台词/音效', '备注']
# 定义各列宽度比例
widths = ['5%', '8%', '7%', '20%', '10%', '10%', '10%', '10%', '10%', '10%']
for i, (header, width) in enumerate(zip(headers, widths)):
# 表头样式:加粗,深色,底边框 (使用蓝色 #1a73e8)
style = f'padding: 14px 16px; font-weight: 700; text-align: left; color: #202124; border-bottom: 2px solid #1a73e8; width: {width};'
html += f'<th style="{style}">{header}</th>'
html += '</tr></thead>'
# 表体
html += '<tbody>'
for index, row in enumerate(self.table_data):
# 隔行变色 (浅色)
bg_color = "#ffffff" if index % 2 == 0 else "#f8f9fa"
html += f'<tr style="background-color: {bg_color};">'
for i, header in enumerate(headers):
value = row.get(header, '')
cell_style = 'padding: 14px 16px; border-bottom: 1px solid #f1f3f4; vertical-align: top;'
# 特殊列样式处理
content = value
if header == '镜号': # 镜号 - 蓝色徽章 (Light Mode)
content = f'<span style="display: inline-block; background-color: #e8f0fe; color: #1967d2; border: 1px solid #d2e3fc; padding: 2px 8px; border-radius: 12px; font-weight: bold; font-size: 12px;">{value}</span>'
elif header == '景别': # 景别 - 标签风格 (Light Mode)
if "全" in value or "远" in value:
bg, color = "#e6f4ea", "#137333" # Green
elif "中" in value:
bg, color = "#fef7e0", "#ea8600" # Yellow/Orange
else:
bg, color = "#fce8e6", "#c5221f" # Red
content = f'<span style="display: inline-block; background-color: {bg}; color: {color}; padding: 2px 6px; border-radius: 4px; font-size: 12px;">{value}</span>'
elif header == '画面内容': # 画面内容 - 加黑
cell_style += ' font-weight: 500;'
html += f'<td style="{cell_style}">{content}</td>'
html += '</tr>'
html += '</tbody></table></div>'
self.table_html.setHtml(html)
# 根据内容调整节点大小
content_height = self.table_html.boundingRect().height()
new_height = max(800, content_height + 100) # 最小高度800,留足底部空间
self.setRect(0, 0, 1400, new_height)
self.table_html.setTextWidth(1400 - 40)
def set_table_data(self, data):
"""设置表格数据"""
self.table_data = data
self.update_table_display()
# ==================== 无限画布视图 ====================
class InfiniteCanvasView(QGraphicsView):
"""无限画布视图 - 支持拖动、缩放、删除节点、连接节点"""
node_selected = Signal(object) # 节点选中信号
canvas_clicked = Signal() # 画布点击信号
def __init__(self, parent=None):
super().__init__(parent)
# 创建场景
self.scene = QGraphicsScene()
# 设置一个足够大的场景范围,让用户感觉是无限的
# 使用 -100000 到 100000 应该足够覆盖大部分用例
self.scene.setSceneRect(-100000, -100000, 200000, 200000)
self.setScene(self.scene)
# 连接场景的选中改变信号
self.scene.selectionChanged.connect(self.on_selection_changed)
# 设置视图属性
self.setRenderHint(QPainter.RenderHint.Antialiasing)
self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.FullViewportUpdate)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
# 背景颜色
self.setStyleSheet("background-color: #ffffff;")
# 拖动状态
self.is_panning = False
self.last_pan_point = QPointF()
# 缩放范围
self.zoom_factor = 1.0
self.min_zoom = 0.1
self.max_zoom = 3.0
self.show_grid = False
if self.show_grid:
self.draw_grid()
# 启用键盘焦点
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# 启用拖放
self.setAcceptDrops(True)
# ========== 连接功能 ==========
self.connection_manager = ConnectionManager(self.scene)
self.is_dragging_connection = False
self.hover_socket = None
# 连接信号
self.connection_manager.connection_created.connect(self.on_connection_created)
self.connection_manager.connection_removed.connect(self.on_connection_removed)
# ========== 撤销功能 ==========
self.undo_stack = [] # 撤销栈:存储操作历史
self.max_undo_steps = 50 # 最大撤销步数
# ========== 节点计数器(用于自动命名)==========
self.storyboard_counter = 0 # 分镜脚本节点计数器
# ========== 剪贴板功能 ==========
self.clipboard_data = []
self.paste_offset_count = 0
# print("[画布] 节点连线功能已启用")
# print("[画布] 撤销功能已启用 (Ctrl+Z)")
def draw_grid(self):
"""绘制网格背景"""
# 添加细网格
for x in range(-5000, 5000, 50):
line = self.scene.addLine(x, -5000, x, 5000, QPen(QColor("#e6f4ea"), 1))
line.setZValue(-1000)
for y in range(-5000, 5000, 50):
line = self.scene.addLine(-5000, y, 5000, y, QPen(QColor("#e6f4ea"), 1))
line.setZValue(-1000)
# 添加粗网格
for x in range(-5000, 5000, 200):
line = self.scene.addLine(x, -5000, x, 5000, QPen(QColor("#c8e6c9"), 2))
line.setZValue(-999)
for y in range(-5000, 5000, 200):
line = self.scene.addLine(-5000, y, 5000, y, QPen(QColor("#c8e6c9"), 2))
line.setZValue(-999)
def keyPressEvent(self, event):
"""键盘事件 - Delete删除、Ctrl+Z撤销、Ctrl+C复制、Ctrl+V粘贴"""
# 导入外部删除逻辑 (用户请求)
try:
import deteled
if deteled.handle_delete(self, event):
event.accept()
return
except Exception as e:
print(f"[KeyError] deteled.py execution failed: {e}")
import traceback
traceback.print_exc()
# 检查是否有输入控件获取了焦点
focus_item = self.scene.focusItem()
app_focus_widget = QApplication.focusWidget()
# 如果场景中有焦点项是代理控件,或者应用程序焦点在非视图组件上(修复输入框焦点不同步问题)
if (focus_item and isinstance(focus_item, QGraphicsProxyWidget)) or \
(app_focus_widget and app_focus_widget != self):
# 如果焦点在代理控件上(如输入框、表格),则不处理视图级快捷键,直接传递给控件
super().keyPressEvent(event)
return
# Ctrl+C 复制
if event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_C:
self.copy_selected_nodes()
event.accept()
return
# Ctrl+V 粘贴
if event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_V:
self.paste_nodes()
event.accept()
return
# Ctrl+Z 撤销
if event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_Z:
self.undo_last_operation()
event.accept()
return
# Delete 或 Backspace 删除选中项
# 已通过 deteled.py 处理,这里保留作为Fallback
if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:
# 只有当 deteled.py 没有处理时才会走到这里(通常不会,除非导入失败)
pass
super().keyPressEvent(event)
def dragEnterEvent(self, event):
# 优先让 Item 处理 (如节点内的上传区域)
super().dragEnterEvent(event)
if event.isAccepted():
return
md = event.mimeData()
# 资料库拖拽: 只能拖到节点里,不能拖到空白处
if md.hasFormat("application/x-ghost-library-image"):
event.ignore()
# 外部文件: 可以拖到空白处生成节点
elif md.hasUrls():
event.accept()
def dragMoveEvent(self, event):
# 优先让 Item 处理
super().dragMoveEvent(event)
if event.isAccepted():
return
md = event.mimeData()
if md.hasFormat("application/x-ghost-library-image"):
event.ignore()
elif md.hasUrls():